feat(api): Implement /api/v1/markers

This commit is contained in:
Jesse Wierzbinski 2024-04-16 21:04:03 -10:00
parent 88b3ec7b43
commit bf0153627e
No known key found for this signature in database
8 changed files with 4039 additions and 0 deletions

View 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 $$;

View 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 $$;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }

View file

@ -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")

View 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,
},
});
});
});

View 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);
}
}
},
);