diff --git a/bun.lockb b/bun.lockb index 1736690..2922ad3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/0002_public_whistler.sql b/drizzle/0002_public_whistler.sql new file mode 100644 index 0000000..8453ba5 --- /dev/null +++ b/drizzle/0002_public_whistler.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `token_token_unique` ON `token` (`token`); \ No newline at end of file diff --git a/drizzle/0003_puzzling_nightmare.sql b/drizzle/0003_puzzling_nightmare.sql new file mode 100644 index 0000000..52ac4ad --- /dev/null +++ b/drizzle/0003_puzzling_nightmare.sql @@ -0,0 +1,20 @@ +-- Custom SQL migration file, put you code below! -- +DROP TABLE `watch_status`; +--> statement-breakpoint +DROP TABLE `token`; +--> statement-breakpoint + +CREATE TABLE `token` ( + `device_id` text NOT NULL, + `token` text NOT NULL UNIQUE ON CONFLICT FAIL, + `username` text, + `last_connected_at` text DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY(`device_id`) ON CONFLICT REPLACE +); +--> statement-breakpoint +CREATE TABLE `watch_status` ( + `device_id` text NOT NULL, + `title_id` integer NOT NULL, + PRIMARY KEY(`device_id`, `title_id`), + FOREIGN KEY (`device_id`) REFERENCES `token`(`device_id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/0004_jittery_black_knight.sql b/drizzle/0004_jittery_black_knight.sql new file mode 100644 index 0000000..b8a9b7b --- /dev/null +++ b/drizzle/0004_jittery_black_knight.sql @@ -0,0 +1,20 @@ +-- Custom SQL migration file, put you code below! -- +DROP TABLE `watch_status`; +--> statement-breakpoint +DROP TABLE `token`; +--> statement-breakpoint + +CREATE TABLE `device_tokens` ( + `device_id` text NOT NULL, + `token` text NOT NULL UNIQUE ON CONFLICT FAIL, + `username` text, + `last_connected_at` text DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY(`device_id`) ON CONFLICT REPLACE +); +--> statement-breakpoint +CREATE TABLE `watch_status` ( + `device_id` text NOT NULL, + `title_id` integer NOT NULL, + PRIMARY KEY(`device_id`, `title_id`), + FOREIGN KEY (`device_id`) REFERENCES `device_tokens`(`device_id`) ON UPDATE no action ON DELETE no action +); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..46f8920 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,99 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "548c249b-15e5-4a6a-a3e4-c539d8774728", + "prevId": "d5b8fe62-fa26-4e9b-94eb-d3d38701f620", + "tables": { + "token": { + "name": "token", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "token_token_unique": { + "name": "token_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_token_device_id_fk": { + "name": "watch_status_device_id_token_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "token", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7383519 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,99 @@ +{ + "id": "3c96b576-9b17-42f7-9dd3-908a83c2a94b", + "prevId": "548c249b-15e5-4a6a-a3e4-c539d8774728", + "version": "6", + "dialect": "sqlite", + "tables": { + "token": { + "name": "token", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "token_token_unique": { + "name": "token_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_token_device_id_fk": { + "name": "watch_status_device_id_token_device_id_fk", + "tableFrom": "watch_status", + "columnsFrom": ["device_id"], + "tableTo": "token", + "columnsTo": ["device_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..dab66a4 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,101 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "223cc621-0232-4499-973a-9013d134b1f9", + "prevId": "3c96b576-9b17-42f7-9dd3-908a83c2a94b", + "tables": { + "device_tokens": { + "name": "device_tokens", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "device_tokens_token_unique": { + "name": "device_tokens_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_device_tokens_device_id_fk": { + "name": "watch_status_device_id_device_tokens_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "device_tokens", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": { + "\"token\"": "\"device_tokens\"" + }, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cc2ceb2..2960175 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1718107695989, "tag": "0001_purple_franklin_richards", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1718281079139, + "tag": "0002_public_whistler", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1718394970979, + "tag": "0003_puzzling_nightmare", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1718402777422, + "tag": "0004_jittery_black_knight", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 6454fe6..c83bb48 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "@cloudflare/workers-types": "^4.20240403.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/bun": "^1.1.2", + "@types/luxon": "^3.4.2", "drizzle-kit": "^0.22.6", "husky": "^9.0.11", "lint-staged": "^15.2.7", + "luxon": "^3.4.4", "msw": "^2.3.0", "prettier": "^3.2.5", "prettier-plugin-toml": "^2.0.1", diff --git a/src/controllers/token/index.spec.ts b/src/controllers/token/index.spec.ts new file mode 100644 index 0000000..3a0994b --- /dev/null +++ b/src/controllers/token/index.spec.ts @@ -0,0 +1,197 @@ +import { eq, sql } from "drizzle-orm"; +import { DateTime } from "luxon"; + +import { beforeEach, describe, expect, it } from "bun:test"; + +import app from "~/index"; +import { server } from "~/mocks"; +import { getDb, resetDb } from "~/models/db"; +import { deviceTokensTable } from "~/models/schema"; + +server.listen(); + +describe("requests the /token route", () => { + const db = getDb({ + TURSO_URL: process.env.TURSO_URL ?? "http://127.0.0.1:3000", + TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN ?? "asd", + }); + + beforeEach(async () => { + await resetDb(); + }); + + it("should succeed", async () => { + const res = await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "123", username: "test" }), + }); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("succeeded, db should contain entry", async () => { + const minimumTimestamp = DateTime.now(); + await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "123", username: "test" }), + }); + + const row = await db + .select() + .from(deviceTokensTable) + .where(eq(deviceTokensTable.deviceId, "123")) + .get(); + + expect(row).toEqual({ + deviceId: "123", + token: "123", + username: "test", + lastConnectedAt: expect.any(String), + }); + // since SQL timestamp doesn't support milliseconds, compare to nearest second + expect( + +DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf( + "second", + ), + ).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second")); + }); + + it("with username as null, should succeed", async () => { + const res = await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "123", username: null }), + }); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("with username as null, db should contain entry", async () => { + const minimumTimestamp = DateTime.now(); + await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "123", username: null }), + }); + + const row = await db + .select() + .from(deviceTokensTable) + .where(eq(deviceTokensTable.deviceId, "123")) + .get(); + + expect(row).toEqual({ + deviceId: "123", + token: "123", + username: null, + lastConnectedAt: expect.any(String), + }); + // since SQL timestamp doesn't support milliseconds, compare to nearest second + expect( + +DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf( + "second", + ), + ).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second")); + }); + + it("device id already exists in db, should succeed", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "123" }); + + const res = await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "124", deviceId: "123", username: null }), + }); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("device id already exists in db, should contain new token", async () => { + const minimumTimestamp = DateTime.now(); + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "123" }); + await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "124", deviceId: "123", username: null }), + }); + + const row = await db + .select() + .from(deviceTokensTable) + .where(eq(deviceTokensTable.deviceId, "123")) + .get(); + + expect(row).toEqual({ + deviceId: "123", + token: "124", + username: null, + lastConnectedAt: expect.any(String), + }); + // since SQL timestamp doesn't support milliseconds, compare to nearest second + expect( + +DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf( + "second", + ), + ).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second")); + }); + + it("token already exists in db, should fail", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "123" }); + + const res = await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "124", username: null }), + }); + + expect(res.json()).resolves.toEqual({ success: false }); + expect(res.status).toBe(412); + }); + + it("token already exists in db, should not insert new entry", async () => { + const minimumTimestamp = DateTime.now(); + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "123" }); + await app.request("/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ token: "123", deviceId: "124", username: null }), + }); + + const row = await db + .select() + .from(deviceTokensTable) + .where(eq(deviceTokensTable.deviceId, "124")) + .get(); + + expect(row).toBeUndefined(); + }); +}); diff --git a/src/controllers/token/index.ts b/src/controllers/token/index.ts new file mode 100644 index 0000000..905e07d --- /dev/null +++ b/src/controllers/token/index.ts @@ -0,0 +1,70 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import { env } from "hono/adapter"; + +import { saveToken } from "~/models/token"; +import type { Env } from "~/types/env"; +import { + ErrorResponse, + SuccessResponse, + SuccessResponseSchema, +} from "~/types/schema"; + +const app = new OpenAPIHono(); + +const SaveTokenRequest = z.object({ + token: z.string(), + deviceId: z.string(), + username: z.string().nullable(), +}); + +const SaveTokenResponse = SuccessResponseSchema(); + +const route = createRoute({ + tags: ["aniplay", "notifications"], + operationId: "saveToken", + summary: "Saves FCM token", + method: "post", + path: "/", + request: { + body: { + content: { + "application/json": { + schema: SaveTokenRequest, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: SaveTokenResponse, + }, + }, + description: "Saved token successfully", + }, + }, +}); + +app.openapi(route, async (c) => { + const { token, deviceId, username } = + await c.req.json(); + + try { + await saveToken(env(c, "workerd"), deviceId, token, username); + } catch (error) { + if ( + error.code === "SQLITE_CONSTRAINT" && + error.message.includes("device_tokens.token") + ) { + return c.json(ErrorResponse, 412); + } + + console.error(new Error("Failed to save token", { cause: error })); + return c.json(ErrorResponse, 500); + } + + return c.json(SuccessResponse); +}); + +export default app; diff --git a/src/controllers/watch-status/index.spec.ts b/src/controllers/watch-status/index.spec.ts index 0907bd4..a753fbb 100644 --- a/src/controllers/watch-status/index.spec.ts +++ b/src/controllers/watch-status/index.spec.ts @@ -3,10 +3,9 @@ import { beforeEach, describe, expect, it } from "bun:test"; import app from "~/index"; import { server } from "~/mocks"; import { getDb, resetDb } from "~/models/db"; -import { tokenTable } from "~/models/schema"; +import { deviceTokensTable } from "~/models/schema"; server.listen(); -console.error = () => {}; describe("requests the /watch-status route", () => { const db = getDb({ @@ -19,7 +18,9 @@ describe("requests the /watch-status route", () => { }); it("saving title, deviceId in db, should succeed", async () => { - await db.insert(tokenTable).values({ deviceId: "123", token: "asd" }); + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); const res = await app.request( "/watch-status", @@ -71,7 +72,9 @@ describe("requests the /watch-status route", () => { }); it("saving title, Anilist request fails, should succeed", async () => { - await db.insert(tokenTable).values({ deviceId: "123", token: "asd" }); + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); const res = await app.request( "/watch-status", diff --git a/src/index.ts b/src/index.ts index fdfc163..243deaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,10 @@ app.route( (controller) => controller.default, ), ); +app.route( + "/token", + await import("~/controllers/token").then((controller) => controller.default), +); // The OpenAPI documentation will be available at /doc app.doc("/openapi.json", { diff --git a/src/models/db.ts b/src/models/db.ts index 82feac2..bd51b4e 100644 --- a/src/models/db.ts +++ b/src/models/db.ts @@ -1,5 +1,4 @@ import { createClient } from "@libsql/client"; -import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/libsql"; import type { Env } from "~/types/env"; diff --git a/src/models/schema.ts b/src/models/schema.ts index df8993d..21a2521 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -6,11 +6,11 @@ import { text, } from "drizzle-orm/sqlite-core"; -export const tokenTable = sqliteTable("token", { +export const deviceTokensTable = sqliteTable("device_tokens", { deviceId: text("device_id").primaryKey(), - token: text("token").notNull(), + token: text("token").notNull().unique(), username: text("username"), - /** Used to determine if a device hasn't been used in a while. Should start to ignore tokens where the device hasn't connected in about a month */ + /** Used to determine if a device hasn't been used in a while. Should start to ignore tokens where the device hasn't connected in about a month. */ lastConnectedAt: text("last_connected_at").default(sql`(CURRENT_TIMESTAMP)`), }); @@ -19,7 +19,7 @@ export const watchStatusTable = sqliteTable( { deviceId: text("device_id") .notNull() - .references(() => tokenTable.deviceId), + .references(() => deviceTokensTable.deviceId), titleId: integer("title_id").notNull(), }, (table) => ({ @@ -27,4 +27,4 @@ export const watchStatusTable = sqliteTable( }), ); -export const tables = [watchStatusTable, tokenTable]; +export const tables = [watchStatusTable, deviceTokensTable]; diff --git a/src/models/token.ts b/src/models/token.ts index 8ee48ad..703a8f0 100644 --- a/src/models/token.ts +++ b/src/models/token.ts @@ -3,7 +3,7 @@ import { eq, sql } from "drizzle-orm"; import type { Env } from "~/types/env"; import { getDb } from "./db"; -import { tokenTable } from "./schema"; +import { deviceTokensTable } from "./schema"; export function saveToken( env: Env, @@ -12,15 +12,15 @@ export function saveToken( username: string | null, ) { return getDb(env) - .insert(tokenTable) + .insert(deviceTokensTable) .values({ deviceId, token, username }) .run(); } export function updateDeviceLastConnectedAt(env: Env, deviceId: string) { return getDb(env) - .update(tokenTable) + .update(deviceTokensTable) .set({ lastConnectedAt: sql`(CURRENT_TIMESTAMP)` }) - .where(eq(tokenTable.deviceId, deviceId)) + .where(eq(deviceTokensTable.deviceId, deviceId)) .run(); }