diff --git a/src/app/(admin)/settings/account/page.tsx b/src/app/(admin)/settings/account/page.tsx index 068c58e..82bebe5 100644 --- a/src/app/(admin)/settings/account/page.tsx +++ b/src/app/(admin)/settings/account/page.tsx @@ -1,6 +1,7 @@ import { getCurrentSession } from "@/lib/auth/session"; import { ChangePasswordForm } from "./change-password-form"; import { TwoFactorSettings } from "./two-factor-settings"; +import { PushSubscriptionsSettings } from "./push-subscriptions-settings"; export default async function AccountSettingsPage() { const session = await getCurrentSession(); @@ -15,6 +16,9 @@ export default async function AccountSettingsPage() { )} +
+ +
); } diff --git a/src/app/(admin)/settings/account/push-subscriptions-settings.tsx b/src/app/(admin)/settings/account/push-subscriptions-settings.tsx new file mode 100644 index 0000000..c3c7508 --- /dev/null +++ b/src/app/(admin)/settings/account/push-subscriptions-settings.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BellOff, Loader2 } from "lucide-react"; +import { formatRelativeTime } from "@/lib/format"; + +type Subscription = { id: string; userAgent: string | null; createdAt: string }; + +/** Best-effort "Chrome on Windows"-style label from a stored navigator.userAgent — purely cosmetic, never parsed for anything security-relevant. */ +function describeUserAgent(userAgent: string | null): string { + if (!userAgent) return "Неизвестное устройство"; + + let browser = "Браузер"; + if (/Edg\//.test(userAgent)) browser = "Edge"; + else if (/OPR\//.test(userAgent)) browser = "Opera"; + else if (/Chrome\//.test(userAgent)) browser = "Chrome"; + else if (/Firefox\//.test(userAgent)) browser = "Firefox"; + else if (/Safari\//.test(userAgent)) browser = "Safari"; + + let os = ""; + if (/Windows/.test(userAgent)) os = "Windows"; + else if (/Android/.test(userAgent)) os = "Android"; + else if (/iPhone|iPad/.test(userAgent)) os = "iOS"; + else if (/Macintosh|Mac OS X/.test(userAgent)) os = "macOS"; + else if (/Linux/.test(userAgent)) os = "Linux"; + + return os ? `${browser} на ${os}` : browser; +} + +export function PushSubscriptionsSettings() { + const [subscriptions, setSubscriptions] = useState(null); + const [removingId, setRemovingId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/push/subscriptions") + .then((res) => res.json()) + .then((data) => setSubscriptions(data.subscriptions ?? [])) + .catch(() => setError("Не удалось загрузить список устройств")); + }, []); + + async function unsubscribe(id: string) { + setRemovingId(id); + setError(null); + try { + const res = await fetch(`/api/push/subscriptions/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + setSubscriptions((prev) => (prev ? prev.filter((s) => s.id !== id) : prev)); + } catch { + setError("Не удалось отписать устройство — попробуйте ещё раз"); + } finally { + setRemovingId(null); + } + } + + return ( +
+

Push-уведомления

+

+ Устройства/браузеры, подписанные на уведомления этого аккаунта. Если один и тот же браузер получает + уведомление дважды или трижды, скорее всего в нескольких браузерах на этом компьютере отдельно разрешены + уведомления для сайта — отпишите лишние здесь. +

+ + {error &&

{error}

} + + {subscriptions === null ? ( +

+ + Загрузка… +

+ ) : subscriptions.length === 0 ? ( +

Нет подписанных устройств.

+ ) : ( +
    + {subscriptions.map((sub) => ( +
  • +
    +

    {describeUserAgent(sub.userAgent)}

    +

    Подписан {formatRelativeTime(new Date(sub.createdAt).getTime())}

    +
    + +
  • + ))} +
+ )} +
+ ); +} 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 { const saveRes = await fetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(subscription.toJSON()), + body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }), }); if (saveRes.ok) { console.log("[push] subscription saved server-side — done"); diff --git a/src/lib/push/service.ts b/src/lib/push/service.ts index 7496ebe..d5107c4 100644 --- a/src/lib/push/service.ts +++ b/src/lib/push/service.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, and } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { pushSubscriptions, users } from "@/lib/db/schema"; import type { TicketDTO, MessageDTO } from "@/lib/tickets/types"; @@ -10,13 +10,17 @@ export interface PushSubscriptionKeys { } /** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */ -export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise { +export async function savePushSubscription( + userId: string, + sub: PushSubscriptionKeys, + userAgent?: string, +): Promise { await db .insert(pushSubscriptions) - .values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }) + .values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent }) .onConflictDoUpdate({ target: pushSubscriptions.endpoint, - set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth }, + set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent }, }); } @@ -24,6 +28,22 @@ export async function deletePushSubscriptionByEndpoint(endpoint: string): Promis await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)); } +/** A user's own subscribed devices/browsers, for the "manage notifications" settings list — never exposes the endpoint/keys, only what's useful to tell rows apart. */ +export async function listPushSubscriptionsForUser(userId: string) { + const rows = await db.query.pushSubscriptions.findMany({ + where: eq(pushSubscriptions.userId, userId), + orderBy: (t, { desc }) => desc(t.createdAt), + }); + return rows.map((r) => ({ id: r.id, userAgent: r.userAgent, createdAt: r.createdAt })); +} + +/** Deletes one of the current user's own subscriptions by id — scoped so a user can only ever remove their own. */ +export async function deleteOwnPushSubscription(userId: string, id: string): Promise { + await db + .delete(pushSubscriptions) + .where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId))); +} + async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise { if (userIds.length === 0 || !ensureVapidConfigured()) return;