+
Push-уведомления
+
+ Устройства/браузеры, подписанные на уведомления этого аккаунта. Если один и тот же браузер получает
+ уведомление дважды или трижды, скорее всего в нескольких браузерах на этом компьютере отдельно разрешены
+ уведомления для сайта — отпишите лишние здесь.
+
+
+ {error &&
{error}
}
+
+ {subscriptions === null ? (
+
+
+ Загрузка…
+
+ ) : subscriptions.length === 0 ? (
+
Нет подписанных устройств.
+ ) : (
+
+ {subscriptions.map((sub) => (
+
+
+
{describeUserAgent(sub.userAgent)}
+
Подписан {formatRelativeTime(new Date(sub.createdAt).getTime())}
+
+ unsubscribe(sub.id)}
+ className="btn btn-ghost shrink-0 px-2.5 py-1.5 text-xs"
+ >
+
+ Отписать
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts
index 57cdcf3..e0ed437 100644
--- a/src/app/api/push/subscribe/route.ts
+++ b/src/app/api/push/subscribe/route.ts
@@ -11,6 +11,7 @@ const subscribeSchema = z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
+ userAgent: z.string().max(500).optional(),
});
export async function POST(request: Request) {
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
- await savePushSubscription(session.user.id, parsed.data);
+ await savePushSubscription(session.user.id, parsed.data, parsed.data.userAgent);
return NextResponse.json({ ok: true }, { status: 201 });
}
diff --git a/src/app/api/push/subscriptions/[id]/route.ts b/src/app/api/push/subscriptions/[id]/route.ts
new file mode 100644
index 0000000..ed57504
--- /dev/null
+++ b/src/app/api/push/subscriptions/[id]/route.ts
@@ -0,0 +1,15 @@
+export const runtime = "nodejs";
+
+import { NextResponse } from "next/server";
+import { requireSession } from "@/lib/auth/require";
+import { deleteOwnPushSubscription } from "@/lib/push/service";
+
+/** Unsubscribes one of the current user's own devices/browsers — scoped by userId, so a user can never remove someone else's. */
+export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { session, response } = await requireSession();
+ if (!session) return response;
+
+ const { id } = await params;
+ await deleteOwnPushSubscription(session.user.id, id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/push/subscriptions/route.ts b/src/app/api/push/subscriptions/route.ts
new file mode 100644
index 0000000..7a10870
--- /dev/null
+++ b/src/app/api/push/subscriptions/route.ts
@@ -0,0 +1,14 @@
+export const runtime = "nodejs";
+
+import { NextResponse } from "next/server";
+import { requireSession } from "@/lib/auth/require";
+import { listPushSubscriptionsForUser } from "@/lib/push/service";
+
+/** The current user's own subscribed devices/browsers, for the notification settings list. */
+export async function GET() {
+ const { session, response } = await requireSession();
+ if (!session) return response;
+
+ const subscriptions = await listPushSubscriptionsForUser(session.user.id);
+ return NextResponse.json({ subscriptions });
+}
diff --git a/src/lib/db/migrations/0011_cute_boomer.sql b/src/lib/db/migrations/0011_cute_boomer.sql
new file mode 100644
index 0000000..7e671cc
--- /dev/null
+++ b/src/lib/db/migrations/0011_cute_boomer.sql
@@ -0,0 +1 @@
+ALTER TABLE `push_subscriptions` ADD `user_agent` text;
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/0011_snapshot.json b/src/lib/db/migrations/meta/0011_snapshot.json
new file mode 100644
index 0000000..a4fcc4c
--- /dev/null
+++ b/src/lib/db/migrations/meta/0011_snapshot.json
@@ -0,0 +1,1201 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "1dab7fda-b240-4188-851a-94d6512231bd",
+ "prevId": "a7a6e5be-42a8-4647-9afd-584e40132feb",
+ "tables": {
+ "attachments": {
+ "name": "attachments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_id": {
+ "name": "content_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_inline": {
+ "name": "is_inline",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "attachments_message_id_messages_id_fk": {
+ "name": "attachments_message_id_messages_id_fk",
+ "tableFrom": "attachments",
+ "tableTo": "messages",
+ "columnsFrom": [
+ "message_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "canned_responses": {
+ "name": "canned_responses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customers": {
+ "name": "customers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "telegram_chat_id": {
+ "name": "telegram_chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "portal_token": {
+ "name": "portal_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {
+ "customers_telegram_chat_id_unique": {
+ "name": "customers_telegram_chat_id_unique",
+ "columns": [
+ "telegram_chat_id"
+ ],
+ "isUnique": true
+ },
+ "customers_portal_token_unique": {
+ "name": "customers_portal_token_unique",
+ "columns": [
+ "portal_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ldap_config": {
+ "name": "ldap_config",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 389
+ },
+ "use_tls": {
+ "name": "use_tls",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "bind_dn": {
+ "name": "bind_dn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bind_password_enc": {
+ "name": "bind_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "base_dn": {
+ "name": "base_dn",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_filter": {
+ "name": "user_filter",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'(mail={{email}})'"
+ },
+ "list_filter": {
+ "name": "list_filter",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'(objectClass=person)'"
+ },
+ "default_role": {
+ "name": "default_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'agent'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "login_challenges": {
+ "name": "login_challenges",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "login_challenges_user_id_users_id_fk": {
+ "name": "login_challenges_user_id_users_id_fk",
+ "tableFrom": "login_challenges",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "mailbox_config": {
+ "name": "mailbox_config",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imap_host": {
+ "name": "imap_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "imap_port": {
+ "name": "imap_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 993
+ },
+ "smtp_host": {
+ "name": "smtp_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "smtp_port": {
+ "name": "smtp_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 587
+ },
+ "user": {
+ "name": "user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "allow_insecure_tls": {
+ "name": "allow_insecure_tls",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ticket_id": {
+ "name": "ticket_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_type": {
+ "name": "author_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "body_html": {
+ "name": "body_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'public'"
+ },
+ "email_message_id": {
+ "name": "email_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "messages_ticket_id_tickets_id_fk": {
+ "name": "messages_ticket_id_tickets_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "tickets",
+ "columnsFrom": [
+ "ticket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {
+ "push_subscriptions_endpoint_unique": {
+ "name": "push_subscriptions_endpoint_unique",
+ "columns": [
+ "endpoint"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tags": {
+ "name": "tags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'accent'"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {
+ "tags_name_unique": {
+ "name": "tags_name_unique",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "telegram_config": {
+ "name": "telegram_config",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bot_token_enc": {
+ "name": "bot_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bot_username": {
+ "name": "bot_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ticket_tags": {
+ "name": "ticket_tags",
+ "columns": {
+ "ticket_id": {
+ "name": "ticket_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ticket_tags_ticket_id_tickets_id_fk": {
+ "name": "ticket_tags_ticket_id_tickets_id_fk",
+ "tableFrom": "ticket_tags",
+ "tableTo": "tickets",
+ "columnsFrom": [
+ "ticket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ticket_tags_tag_id_tags_id_fk": {
+ "name": "ticket_tags_tag_id_tags_id_fk",
+ "tableFrom": "ticket_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ticket_tags_ticket_id_tag_id_pk": {
+ "columns": [
+ "ticket_id",
+ "tag_id"
+ ],
+ "name": "ticket_tags_ticket_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tickets": {
+ "name": "tickets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'new'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'normal'"
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assignee_id": {
+ "name": "assignee_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tickets_customer_id_customers_id_fk": {
+ "name": "tickets_customer_id_customers_id_fk",
+ "tableFrom": "tickets",
+ "tableTo": "customers",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tickets_assignee_id_users_id_fk": {
+ "name": "tickets_assignee_id_users_id_fk",
+ "tableFrom": "tickets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "assignee_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "totp_recovery_codes": {
+ "name": "totp_recovery_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code_hash": {
+ "name": "code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "totp_recovery_codes_user_id_users_id_fk": {
+ "name": "totp_recovery_codes_user_id_users_id_fk",
+ "tableFrom": "totp_recovery_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'agent'"
+ },
+ "auth_source": {
+ "name": "auth_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'local'"
+ },
+ "totp_secret": {
+ "name": "totp_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "totp_enabled": {
+ "name": "totp_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "widget_sites": {
+ "name": "widget_sites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "site_key": {
+ "name": "site_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "allowed_origin": {
+ "name": "allowed_origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch('subsec') * 1000)"
+ }
+ },
+ "indexes": {
+ "widget_sites_site_key_unique": {
+ "name": "widget_sites_site_key_unique",
+ "columns": [
+ "site_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json
index 1c86166..272d7b2 100644
--- a/src/lib/db/migrations/meta/_journal.json
+++ b/src/lib/db/migrations/meta/_journal.json
@@ -78,6 +78,13 @@
"when": 1787213219014,
"tag": "0010_curved_jack_murdock",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "6",
+ "when": 1788763637424,
+ "tag": "0011_cute_boomer",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 539cef1..9856ef7 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -287,5 +287,10 @@ export const pushSubscriptions = sqliteTable("push_subscriptions", {
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
+ // Raw navigator.userAgent at subscribe time, purely for display — lets a
+ // user tell their two subscribed browsers apart when unsubscribing one
+ // (e.g. Windows + both Chrome and Edge granted notification permission,
+ // each getting its own OS toast for the same event).
+ userAgent: text("user_agent"),
createdAt: timestamps.createdAt,
});
diff --git a/src/lib/push/client.ts b/src/lib/push/client.ts
index bc62e46..b0f34cb 100644
--- a/src/lib/push/client.ts
+++ b/src/lib/push/client.ts
@@ -57,7 +57,7 @@ export async function ensurePushSubscribed(): Promise