mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Implement /api/v1/markers
This commit is contained in:
parent
88b3ec7b43
commit
bf0153627e
19
drizzle/0012_certain_thor_girl.sql
Normal file
19
drizzle/0012_certain_thor_girl.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
CREATE TABLE IF NOT EXISTS "Markers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
|
||||
"noteId" uuid,
|
||||
"userId" uuid,
|
||||
"timeline" text NOT NULL,
|
||||
"created_at" timestamp(3) DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Markers" ADD CONSTRAINT "Markers_noteId_Notes_id_fk" FOREIGN KEY ("noteId") REFERENCES "Notes"("id") ON DELETE cascade ON UPDATE cascade;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Markers" ADD CONSTRAINT "Markers_userId_Users_id_fk" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE cascade ON UPDATE cascade;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
7
drizzle/0013_wandering_celestials.sql
Normal file
7
drizzle/0013_wandering_celestials.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
ALTER TABLE "Markers" ALTER COLUMN "userId" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "Markers" ADD COLUMN "notificationId" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Markers" ADD CONSTRAINT "Markers_notificationId_Notifications_id_fk" FOREIGN KEY ("notificationId") REFERENCES "Notifications"("id") ON DELETE cascade ON UPDATE cascade;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
1829
drizzle/meta/0012_snapshot.json
Normal file
1829
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1848
drizzle/meta/0013_snapshot.json
Normal file
1848
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,20 @@
|
|||
"when": 1713333611707,
|
||||
"tag": "0011_special_the_fury",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1713336108114,
|
||||
"tag": "0012_certain_thor_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1713336611301,
|
||||
"tag": "0013_wandering_celestials",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -26,6 +26,28 @@ export const Emojis = pgTable("Emojis", {
|
|||
}),
|
||||
});
|
||||
|
||||
export const Markers = pgTable("Markers", {
|
||||
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
||||
noteId: uuid("noteId").references(() => Notes.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
notificationId: uuid("notificationId").references(() => Notifications.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
userId: uuid("userId")
|
||||
.references(() => Users.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
timeline: text("timeline").notNull().$type<"home" | "notifications">(),
|
||||
createdAt: timestamp("created_at", { precision: 3, mode: "string" })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const Likes = pgTable("Likes", {
|
||||
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
||||
likerId: uuid("likerId")
|
||||
|
|
|
|||
106
server/api/api/v1/markers/index.test.ts
Normal file
106
server/api/api/v1/markers/index.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
deleteOldTestUsers,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
sendTestRequest,
|
||||
} from "~tests/utils";
|
||||
import { meta } from "./index";
|
||||
|
||||
await deleteOldTestUsers();
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(1);
|
||||
const timeline = await getTestStatuses(10, users[0]);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
// /api/v1/markers
|
||||
describe(meta.route, () => {
|
||||
test("should return 401 if not authenticated", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should return empty markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
`${new URL(
|
||||
meta.route,
|
||||
config.http.base_url,
|
||||
)}?${new URLSearchParams([
|
||||
["timeline[]", "home"],
|
||||
["timeline[]", "notifications"],
|
||||
])}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({});
|
||||
});
|
||||
|
||||
test("should create markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(new URL(meta.route, config.http.base_url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"home[last_read_id]": timeline[0].id,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
home: {
|
||||
last_read_id: timeline[0].id,
|
||||
updated_at: expect.any(String),
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return markers", async () => {
|
||||
const response = await sendTestRequest(
|
||||
new Request(
|
||||
`${new URL(
|
||||
meta.route,
|
||||
config.http.base_url,
|
||||
)}?${new URLSearchParams([
|
||||
["timeline[]", "home"],
|
||||
["timeline[]", "notifications"],
|
||||
])}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].accessToken}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
home: {
|
||||
last_read_id: timeline[0].id,
|
||||
updated_at: expect.any(String),
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
194
server/api/api/v1/markers/index.ts
Normal file
194
server/api/api/v1/markers/index.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { apiRoute, applyConfig, idValidator } from "@api";
|
||||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { fetchTimeline } from "@timelines";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Markers } from "~drizzle/schema";
|
||||
import type { Marker as APIMarker } from "~types/mastodon/marker";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["GET", "POST"],
|
||||
route: "/api/v1/markers",
|
||||
ratelimits: {
|
||||
max: 100,
|
||||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
oauthPermissions: ["read:blocks"],
|
||||
},
|
||||
});
|
||||
|
||||
export const schema = z.object({
|
||||
timeline: z
|
||||
.array(z.enum(["home", "notifications"]))
|
||||
.max(2)
|
||||
.optional(),
|
||||
"home[last_read_id]": z.string().regex(idValidator).optional(),
|
||||
"notifications[last_read_id]": z.string().regex(idValidator).optional(),
|
||||
});
|
||||
|
||||
export default apiRoute<typeof meta, typeof schema>(
|
||||
async (req, matchedRoute, extraData) => {
|
||||
const { user } = extraData.auth;
|
||||
|
||||
if (!user) return errorResponse("Unauthorized", 401);
|
||||
|
||||
switch (req.method) {
|
||||
case "GET": {
|
||||
const { timeline } = extraData.parsedRequest;
|
||||
|
||||
if (!timeline) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
|
||||
const markers: APIMarker = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (timeline.includes("home")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }) =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "home"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.noteId) {
|
||||
markers.home = {
|
||||
last_read_id: found.noteId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.includes("notifications")) {
|
||||
const found = await db.query.Markers.findFirst({
|
||||
where: (marker, { and, eq }) =>
|
||||
and(
|
||||
eq(marker.userId, user.id),
|
||||
eq(marker.timeline, "notifications"),
|
||||
),
|
||||
});
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
if (found?.notificationId) {
|
||||
markers.notifications = {
|
||||
last_read_id: found.notificationId,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(found.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(markers);
|
||||
}
|
||||
case "POST": {
|
||||
const {
|
||||
"home[last_read_id]": home_id,
|
||||
"notifications[last_read_id]": notifications_id,
|
||||
} = extraData.parsedRequest;
|
||||
|
||||
const markers: APIMarker = {
|
||||
home: undefined,
|
||||
notifications: undefined,
|
||||
};
|
||||
|
||||
if (home_id) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "home",
|
||||
noteId: home_id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "home"),
|
||||
),
|
||||
);
|
||||
|
||||
markers.home = {
|
||||
last_read_id: home_id,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (notifications_id) {
|
||||
const insertedMarker = (
|
||||
await db
|
||||
.insert(Markers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
timeline: "notifications",
|
||||
notificationId: notifications_id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
const totalCount = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(Markers)
|
||||
.where(
|
||||
and(
|
||||
eq(Markers.userId, user.id),
|
||||
eq(Markers.timeline, "notifications"),
|
||||
),
|
||||
);
|
||||
|
||||
markers.notifications = {
|
||||
last_read_id: notifications_id,
|
||||
version: totalCount[0].count,
|
||||
updated_at: new Date(
|
||||
insertedMarker.createdAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return jsonResponse(markers);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
Loading…
Reference in a new issue