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,
|
"when": 1713333611707,
|
||||||
"tag": "0011_special_the_fury",
|
"tag": "0011_special_the_fury",
|
||||||
"breakpoints": true
|
"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", {
|
export const Likes = pgTable("Likes", {
|
||||||
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
||||||
likerId: uuid("likerId")
|
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