feat: create route to store FCM token

This commit is contained in:
2024-06-14 18:14:10 -04:00
parent 4d3c34579d
commit 231ed4bde4
16 changed files with 650 additions and 14 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `token_token_unique` ON `token` (`token`);

View File

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

View File

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

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

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

View File

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

View File

@@ -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();
});
});

View File

@@ -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<Env>();
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<typeof SaveTokenRequest._type>();
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;

View File

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

View File

@@ -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", {

View File

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

View File

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

View File

@@ -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();
}