diff --git a/.env.example b/.env.example index 81dfda8..9c9b4bb 100644 --- a/.env.example +++ b/.env.example @@ -172,6 +172,14 @@ OAUTH_REDIRECT_ALLOWLIST= # The app's own scheme, e.g. tdn://oauth-success. A flow returning here gets its # refresh token in the exchange response body rather than in a cookie. OAUTH_NATIVE_REDIRECT_ALLOWLIST= +# --- Push notifications --- +# Off swaps in a push service that sends nothing; devices still register. +PUSH_ENABLED=false +# Only needed if the Expo project has push security enabled. +EXPO_ACCESS_TOKEN= +# The app re-registers at every launch, so a device unseen this long is gone. +DEVICE_RETENTION_DAYS=90 +DEVICE_PURGE_CRON=0 6 * * * # --- Mobile clients --- # How long after a rotation a retired refresh token is still accepted as a diff --git a/CLAUDE.md b/CLAUDE.md index 1d56877..8153fc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,18 @@ Unsubscribing is a signed link, no session: an HMAC of the user id under `ACCESS `docs/daily-digest.md` is the operator-facing description — what goes in the email, who receives it, and every knob. +### Push notifications + +A socket only exists while the app is in the foreground, so notifications reach a backgrounded phone through **Expo push** instead. Every notification in the codebase follows the same two lines — store the row, emit `new-notification` — so `PushNotifyingRealtimeService` wraps that emit rather than touching a dozen use cases: it is registered *as* `realtimeService`, delegates to the socket transport, and dispatches `SendPushNotificationUseCase` fire-and-forget behind it. A notification added later is delivered to phones without anybody wiring it up. + +`DeviceToken.token` is unique across the table, not per user: a shared phone or a switched account produces the same token under a new user, so registration **moves** the row instead of leaving one person's notifications on another's screen. `POST`/`DELETE /devices` register and retire; the delete is scoped to the owner, since a push token is not a secret. + +Copy lives in `push-copy.ts` (tr/en), chosen from the **device's** locale rather than the profile's feed languages. The payload carries ids and a type only — and **direct messages are never pushed**: their text is encrypted at rest, and a preview in a push payload would route it through Google's servers. Chat events share the realtime channel and the decorator ignores them by event name. + +Dead tokens go two ways: Expo reports `DeviceNotRegistered` in a ticket and those rows are deleted at once, while a phone that was simply abandoned is caught by `DEVICE_PURGE_CRON` against `lastSeenAt` (the app re-registers at every launch, so age means something here). `PUSH_ENABLED=false` swaps in `NoopPushService` — devices still register, nothing is delivered. + +`docs/push-notifications.md` is the client-facing contract. + ### Realtime and background jobs `FastifyRealtimeService` publishes to the Redis `realtime_events` channel; each instance subscribes and fans out to locally connected sockets via `WebSocketManager` — so notifications work across multiple processes. Never write to sockets directly from a use-case; go through `RealtimePort`. diff --git a/docs/push-notifications.md b/docs/push-notifications.md new file mode 100644 index 0000000..28cce4b --- /dev/null +++ b/docs/push-notifications.md @@ -0,0 +1,95 @@ +# Push notifications + +The realtime socket only exists while the app is in the foreground — both +mobile platforms close it the moment the app is backgrounded. Push is the +second transport, and the only one that reaches a phone nobody is looking at. + +Delivery goes through **Expo**, which owns the FCM credentials (and the APNs +ones when iOS arrives). Everything below is behind `PushPort`, so replacing +Expo with FCM directly is a sibling adapter, not a rewrite. + +## Registering a device + +`POST /api/v1/devices` — authenticated, 5/min. + +```json +{ + "token": "ExponentPushToken[…]", + "platform": "ANDROID", + "appVersion": "42", + "locale": "tr-TR" +} +``` + +Call it **at every launch**, not only the first. The platform can reissue a +token at any time, and re-registering is also what keeps the row from being +swept as abandoned. + +`DELETE /api/v1/devices` with `{ "token": "…" }` retires one. **Call it before +discarding the session on sign-out** — a signed-out phone that is still +registered keeps receiving the previous user's notifications. It is scoped to +the owner: knowing a token is not enough to silence somebody else's phone. + +Both answer `{ "data": { "registered": true|false }, "meta": { … } }` and +nothing more. Whether a row was written, moved or already matched is not +something a client can act on, and "this token belongs to somebody else" is not +something it should learn. + +`token` is unique across the table rather than per user. A phone handed to +somebody else, or an account switched inside the app, produces the *same* token +under a new user — so a registration **moves** the row. + +## What gets sent + +Every notification in the API follows the same two steps: store the row, emit +`new-notification` on the realtime channel. `PushNotifyingRealtimeService` +wraps that emit and pushes behind it, which is why a notification added later +is delivered to phones without anybody remembering to wire it up. + +The copy lives in `push-copy.ts`, in Turkish and English, chosen from the +**device's** locale rather than the profile's feed languages — a notification +is read on a lock screen that is already in one language. + +The payload carries ids and a type, and nothing else: + +```json +{ "type": "COMMENT", "postId": "…", "commentId": "…" } +``` + +**Direct messages are not pushed at all.** Message text is encrypted at rest; +putting even a truncated preview in a push payload would route it through +Google's servers and undo that. Chat events travel the same realtime channel +under their own event names and the decorator ignores them by name. + +## Dead tokens + +Two mechanisms, because one is not enough: + +- Expo reports a token it knows to be dead (`DeviceNotRegistered`) in the + ticket for that message. Those rows are deleted as they are reported. +- A phone that was reset, lost or simply abandoned reports nothing, so + `DEVICE_PURGE_CRON` (06:00 container time) drops registrations not seen for + `DEVICE_RETENTION_DAYS` (90). Since the app re-registers at every launch, age + is a sound signal here. + +Not yet done: Expo's *receipts*, which catch tokens that fail later at FCM +rather than at ticket time. The retention sweep covers the same ground more +slowly; receipts are on the roadmap. + +## Settings + +| Variable | Default | What it does | +| --- | --- | --- | +| `PUSH_ENABLED` | `false` | Off swaps in a service that sends nothing. Devices still register. | +| `EXPO_ACCESS_TOKEN` | _(empty)_ | Required only if the Expo project has push security enabled. | +| `DEVICE_RETENTION_DAYS` | `90` | How long an unseen device is kept. | +| `DEVICE_PURGE_CRON` | `0 6 * * *` | When the sweep runs. | + +## App-side notes + +- Android 13+ needs a runtime notification permission. When it is asked for + decides whether most users enable push or most refuse. +- The badge count comes from the unread notification count and is sent with + every message. +- Tapping a notification should route from `data.type` plus whichever ids are + present — the same destinations the email digest links to. diff --git a/prisma/migrations/20260911000000_add_device_tokens/migration.sql b/prisma/migrations/20260911000000_add_device_tokens/migration.sql new file mode 100644 index 0000000..06b7c6f --- /dev/null +++ b/prisma/migrations/20260911000000_add_device_tokens/migration.sql @@ -0,0 +1,45 @@ +-- Installations of the app that may be notified. +-- +-- "token" is unique across the whole table rather than per user, and that is +-- the point: a phone handed to somebody else, or an account switched inside +-- the app, produces the same token under a new user. Keyed this way a +-- registration moves the row, instead of leaving one person's notifications +-- arriving on another person's screen. +-- +-- "last_seen_at" carries an index because it is what makes an uninstalled app +-- eventually stop being notified. Expo reports a token it knows to be dead and +-- those are deleted at once, but a phone that is simply gone reports nothing, +-- so a token nobody has refreshed for long enough is dropped on age. +-- +-- Cascade on the user, like every other table that points at one: a purged +-- account must not leave a live push token behind. + +-- CreateEnum +CREATE TYPE "public"."DevicePlatform" AS ENUM ('ANDROID', 'IOS'); + +-- CreateTable +CREATE TABLE "public"."device_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "platform" "public"."DevicePlatform" NOT NULL, + "app_version" TEXT, + "locale" TEXT, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "device_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "device_tokens_token_key" ON "public"."device_tokens" ("token"); + +-- CreateIndex +CREATE INDEX "device_tokens_user_id_idx" ON "public"."device_tokens" ("user_id"); + +-- CreateIndex +CREATE INDEX "device_tokens_last_seen_at_idx" ON "public"."device_tokens" ("last_seen_at"); + +-- AddForeignKey +ALTER TABLE "public"."device_tokens" ADD CONSTRAINT "device_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/device.prisma b/prisma/models/device.prisma new file mode 100644 index 0000000..bd536dc --- /dev/null +++ b/prisma/models/device.prisma @@ -0,0 +1,52 @@ +/// Which store's push service a token belongs to. +/// +/// Recorded even though the sending side does not branch on it - Expo resolves +/// FCM and APNs itself - because it is the first thing anybody looks at when a +/// platform stops receiving notifications. +enum DevicePlatform { + ANDROID + IOS +} + +/// One installation of the app that has agreed to receive notifications. +/// +/// The token is unique across the table rather than per user, and that is the +/// point: a phone handed to somebody else, or an account switched inside the +/// app, produces the *same* token under a new user. Keyed this way the +/// registration moves the row instead of leaving one person's notifications +/// arriving on another person's screen. +/// +/// `lastSeenAt` is what makes an uninstalled app eventually stop being +/// notified. Expo reports a token it knows to be dead, and those are deleted +/// immediately, but a phone that is simply gone reports nothing - so a token +/// nobody has refreshed for long enough is dropped on age. +model DeviceToken { + id String @id @default(uuid()) + + /// The Expo push token. Unique across users - see the note on the model. + token String @unique + + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + platform DevicePlatform + + /// Build number of the app that registered, for working out whether a + /// delivery problem is version-shaped. + appVersion String? @map("app_version") + + /// BCP-47 tag from the device, so a notification is written in the language + /// the phone is set to rather than the one the profile asked the feed for. + locale String? + + /// Refreshed on every registration. The app re-registers at launch, so this + /// is a reasonable proxy for "this installation still exists". + lastSeenAt DateTime @default(now()) @map("last_seen_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) + @@index([lastSeenAt]) + @@map("device_tokens") +} diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 9f8255d..bdc72a5 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -69,6 +69,9 @@ model User { /// Both directions of a report, for the cascade rather than for reading: /// a purged account takes its reports and the reports against it with it. + /// Installations of the app that may be notified for this account. + deviceTokens DeviceToken[] + reportsFiled Report[] @relation("ReportsFiled") reportsAgainst Report[] @relation("ReportsAgainst") diff --git a/render.yaml b/render.yaml index 8e8056a..4fe8d7b 100644 --- a/render.yaml +++ b/render.yaml @@ -177,6 +177,17 @@ projects: - key: OAUTH_REDIRECT_ALLOWLIST sync: false - key: OAUTH_NATIVE_REDIRECT_ALLOWLIST + # Push notifications. PUSH_ENABLED is the switch: with it false - the + # default - devices still register and nothing is delivered, so the + # feature can ship before there is an Expo project behind it. + # docs/push-notifications.md has the rest. + - key: PUSH_ENABLED + sync: false + - key: EXPO_ACCESS_TOKEN + sync: false + - key: DEVICE_RETENTION_DAYS + sync: false + - key: DEVICE_PURGE_CRON sync: false # Mobile clients. All four have defaults in env.schema.ts, so the service # boots without them; they are declared because the two build numbers are diff --git a/src/app.ts b/src/app.ts index 610d3dc..f7b979b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,7 @@ import followRoutes from "@routes/profile/follow.routes"; import blockRoutes from "@routes/profile/block.routes"; import reportRoutes from "@routes/report.routes"; import metaRoutes from "@routes/meta.routes"; +import deviceRoutes from "@routes/device.routes"; import websocketPlugin from "./http/plugins/websocket.plugin"; import realtimeRoutes from "@routes/realtime.routes"; import notificationRoutes from "@routes/notification.routes"; @@ -30,6 +31,7 @@ import notificationPurgePlugin from "@plugins/custom/notification-purge.plugin"; import dailyDigestPlugin from "@plugins/custom/daily-digest.plugin"; import reportDigestPlugin from "@plugins/custom/report-digest.plugin"; import reportPurgePlugin from "@plugins/custom/report-purge.plugin"; +import devicePurgePlugin from "@plugins/custom/device-purge.plugin"; import userInterestRebuildPlugin from "@plugins/custom/user-interest-rebuild.plugin"; import mediaModerationPlugin from "@plugins/custom/media-moderation.plugin"; import messageRetentionPlugin from "@plugins/custom/message-retention.plugin"; @@ -120,6 +122,7 @@ export class App { this.server.register(dailyDigestPlugin); this.server.register(reportDigestPlugin); this.server.register(reportPurgePlugin); + this.server.register(devicePurgePlugin); this.server.register(messageRetentionPlugin); } @@ -155,6 +158,8 @@ export class App { this.server.register(metaRoutes, { prefix: "/api/v1" }); + this.server.register(deviceRoutes, { prefix: "/api/v1" }); + this.server.register(realtimeRoutes, { prefix: "/api/v1/realtime" }); this.server.register(notificationRoutes, { diff --git a/src/core/domain/entities/device-token.entity.ts b/src/core/domain/entities/device-token.entity.ts new file mode 100644 index 0000000..aee5497 --- /dev/null +++ b/src/core/domain/entities/device-token.entity.ts @@ -0,0 +1,74 @@ +import type { DevicePlatform } from "@core/domain/enums"; +import type { DeviceTokenProps } from "@core/domain/interfaces/device-token-props.interface"; + +/** + * Rich domain model for one app installation that may be notified. + * + * Thin by design - a token, who it belongs to and what it can tell us about + * the phone - because everything interesting about push lives in deciding what + * to send, not in the address it is sent to. + */ +export class DeviceToken { + private constructor(private readonly props: DeviceTokenProps) {} + + /** + * Creates a registration for a device that has just announced itself. + * + * @param params - The token and what the app knows about the device + * @returns A new DeviceToken instance + */ + public static create(params: { + token: string; + userId: string; + platform: DevicePlatform; + appVersion?: string | null; + locale?: string | null; + }): DeviceToken { + return new DeviceToken({ + token: params.token, + userId: params.userId, + platform: params.platform, + appVersion: params.appVersion ?? null, + locale: params.locale ?? null, + lastSeenAt: new Date(), + }); + } + + /** + * Rebuilds an entity from a persisted row. + * + * @param props - The stored shape + * @returns The DeviceToken instance it describes + */ + public static with(props: DeviceTokenProps): DeviceToken { + return new DeviceToken(props); + } + + get id(): string { + return this.props.id!; + } + + get token(): string { + return this.props.token; + } + + get userId(): string { + return this.props.userId; + } + + get platform(): DevicePlatform { + return this.props.platform; + } + + get appVersion(): string | null { + return this.props.appVersion ?? null; + } + + get locale(): string | null { + return this.props.locale ?? null; + } + + get lastSeenAt(): Date | undefined { + return this.props.lastSeenAt; + } +} diff --git a/src/core/domain/enums/device-platform.enum.ts b/src/core/domain/enums/device-platform.enum.ts new file mode 100644 index 0000000..0d5bcf4 --- /dev/null +++ b/src/core/domain/enums/device-platform.enum.ts @@ -0,0 +1,13 @@ +/** + * Which store's push service a device token belongs to. + * + * Recorded even though the sending side does not branch on it - Expo resolves + * FCM and APNs itself - because it is the first thing anybody looks at when + * one platform stops receiving notifications. + * + * Mirrors the `DevicePlatform` enum in the Prisma schema exactly. + */ +export enum DevicePlatform { + ANDROID = "ANDROID", + IOS = "IOS", +} diff --git a/src/core/domain/enums/index.ts b/src/core/domain/enums/index.ts index 0c5d7d0..99249b2 100644 --- a/src/core/domain/enums/index.ts +++ b/src/core/domain/enums/index.ts @@ -18,3 +18,4 @@ export { ConversationStatus } from "./conversation-status.enum"; export { ReportTargetKind } from "./report-target-kind.enum"; export { ReportReason } from "./report-reason.enum"; export { ReportStatus } from "./report-status.enum"; +export { DevicePlatform } from "./device-platform.enum"; diff --git a/src/core/domain/interfaces/device-token-props.interface.ts b/src/core/domain/interfaces/device-token-props.interface.ts new file mode 100644 index 0000000..d17c65b --- /dev/null +++ b/src/core/domain/interfaces/device-token-props.interface.ts @@ -0,0 +1,35 @@ +import type { DevicePlatform } from "@core/domain/enums"; + +/** + * The persisted shape of one app installation that may be notified. + */ +export interface DeviceTokenProps { + /** Set once persisted. */ + id?: string; + + /** The Expo push token. Unique across users, not per user. */ + token: string; + + userId: string; + + platform: DevicePlatform; + + /** Build number of the app that registered. */ + appVersion?: string | null; + + /** + * BCP-47 tag from the device. + * + * The phone's language rather than the profile's feed languages: a + * notification is read on the lock screen, in whatever language that + * screen is already in. + */ + locale?: string | null; + + /** Refreshed on every registration. */ + lastSeenAt?: Date; + + createdAt?: Date; + + updatedAt?: Date; +} diff --git a/src/core/ports/repositories/device-token.repository.ts b/src/core/ports/repositories/device-token.repository.ts new file mode 100644 index 0000000..e03d1e2 --- /dev/null +++ b/src/core/ports/repositories/device-token.repository.ts @@ -0,0 +1,61 @@ +import type { DeviceToken } from "@core/domain/entities/device-token.entity"; + +/** + * Repository interface for app installations that may be notified. + */ +export interface IDeviceTokenRepository { + /** + * Records a device against an account, moving it if it was registered to + * somebody else. + * + * Keyed on the token rather than on the pair, deliberately. Phones get + * handed over and accounts get switched inside the app; both produce the + * same token under a new user, and anything other than a move would leave + * one person's notifications arriving on another person's screen. + * + * @param device - The registration to write. + * @returns The stored device. + */ + upsert(device: DeviceToken): Promise; + + /** + * Removes one device, if it belongs to the account asking. + * + * Scoped to the owner so that knowing a token is not enough to unregister + * it - tokens travel through the app and are not secrets. + * + * @param token - The push token to remove. + * @param userId - The account it must belong to. + * @returns True when a row was removed. + */ + deleteByToken(token: string, userId: string): Promise; + + /** + * Reads every device registered to an account. + * + * @param userId - The account to look up. + * @returns Its devices, in no particular order. + */ + findByUserId(userId: string): Promise; + + /** + * Removes devices whose tokens the push service has rejected. + * + * @param tokens - The dead tokens. + * @returns How many rows were removed. + */ + deleteByTokens(tokens: string[]): Promise; + + /** + * Removes devices that have not re-registered in a long time. + * + * The app re-registers at launch, so a stale row is an installation that + * is gone. Expo reports the tokens it knows to be dead, but a phone that + * was reset or simply abandoned reports nothing, and age is the only + * signal left. + * + * @param cutoff - Devices last seen before this are removed. + * @returns How many rows were removed. + */ + deleteStale(cutoff: Date): Promise; +} diff --git a/src/core/ports/services/push.port.ts b/src/core/ports/services/push.port.ts new file mode 100644 index 0000000..308a57b --- /dev/null +++ b/src/core/ports/services/push.port.ts @@ -0,0 +1,64 @@ +/** + * One notification, addressed to one installation. + */ +export interface PushMessage { + /** The Expo push token of the device to reach. */ + to: string; + + /** Short line shown in bold on the lock screen. */ + title: string; + + /** The body beneath it. */ + body: string; + + /** + * What the app needs to open the right screen when it is tapped. + * + * Ids and a type, never content: this payload leaves our infrastructure + * and passes through Google's on the way to the phone. + */ + data: Record; + + /** Unread count to show on the app icon, when the platform supports it. */ + badge?: number; +} + +/** + * What a send attempt achieved. + */ +export interface PushSendResult { + /** How many messages the service accepted. */ + delivered: number; + + /** + * Tokens the service says no longer exist. + * + * Reported rather than logged because they have to be deleted: a token for + * an app that was uninstalled is dead for good, and left in the table it + * would be retried on every notification for the rest of the account's + * life. + */ + invalidTokens: string[]; +} + +/** + * Port interface for delivering push notifications. + * + * Deliberately narrow: it takes messages that are already written and already + * addressed. Deciding *what* to say, in which language, and to which of a + * user's devices belongs to the use case that composes them - this is the wire. + */ +export interface PushPort { + /** + * Sends notifications to the devices they are addressed to. + * + * Must not throw for a delivery failure. A push is a courtesy on top of a + * notification that is already stored and already on the socket, and the + * caller is usually a fire-and-forget path with nothing useful to do about + * a provider being down. + * + * @param messages - The notifications to deliver. + * @returns How many were accepted, and which tokens are dead. + */ + send(messages: PushMessage[]): Promise; +} diff --git a/src/core/use-cases/device/purge-stale-devices/index.ts b/src/core/use-cases/device/purge-stale-devices/index.ts new file mode 100644 index 0000000..cf01cec --- /dev/null +++ b/src/core/use-cases/device/purge-stale-devices/index.ts @@ -0,0 +1,5 @@ +/** + * This module exports the PurgeStaleDevicesUseCase, which drops installations + * that have stopped announcing themselves. + */ +export { PurgeStaleDevicesUseCase } from "./purge-stale-devices.usecase"; diff --git a/src/core/use-cases/device/purge-stale-devices/purge-stale-devices.usecase.ts b/src/core/use-cases/device/purge-stale-devices/purge-stale-devices.usecase.ts new file mode 100644 index 0000000..7c38575 --- /dev/null +++ b/src/core/use-cases/device/purge-stale-devices/purge-stale-devices.usecase.ts @@ -0,0 +1,37 @@ +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; + +/** + * Use case for dropping installations that have stopped announcing themselves. + * + * Expo reports the tokens it knows to be dead and those are deleted the moment + * they are refused, but that only covers apps whose removal the platform + * noticed. A phone that was reset, lost or simply abandoned reports nothing, + * and its token would otherwise be carried - and paid for, in one HTTP call per + * notification - for the rest of the account's life. + * + * The app re-registers at every launch, so age is a sound signal here in a way + * it would not be for something a user only does once. + */ +export class PurgeStaleDevicesUseCase { + /** + * Creates a new instance of PurgeStaleDevicesUseCase. + * + * @param deviceTokenRepository - Where registrations are stored + */ + constructor( + private readonly deviceTokenRepository: IDeviceTokenRepository, + ) {} + + /** + * Executes the sweep. + * + * @param retentionDays - How long an unseen device is kept + * @returns How many registrations were removed + */ + async execute(retentionDays: number): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + return this.deviceTokenRepository.deleteStale(cutoff); + } +} diff --git a/src/core/use-cases/device/register-device/index.ts b/src/core/use-cases/device/register-device/index.ts new file mode 100644 index 0000000..fca90f5 --- /dev/null +++ b/src/core/use-cases/device/register-device/index.ts @@ -0,0 +1,6 @@ +/** + * This module exports the RegisterDeviceUseCase, which records an app + * installation so it can be notified. + */ +export { RegisterDeviceUseCase } from "./register-device.usecase"; +export type { RegisterDeviceInput } from "./register-device.usecase"; diff --git a/src/core/use-cases/device/register-device/register-device.usecase.ts b/src/core/use-cases/device/register-device/register-device.usecase.ts new file mode 100644 index 0000000..86e3fe8 --- /dev/null +++ b/src/core/use-cases/device/register-device/register-device.usecase.ts @@ -0,0 +1,60 @@ +import { DeviceToken } from "@core/domain/entities/device-token.entity"; +import type { DevicePlatform } from "@core/domain/enums"; +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; + +/** + * Input DTO for the RegisterDeviceUseCase. + */ +export interface RegisterDeviceInput { + currentUserId: string; + + /** The Expo push token the app was issued. */ + token: string; + + platform: DevicePlatform; + + /** Build number of the app registering. */ + appVersion?: string; + + /** BCP-47 tag from the device, for the language notifications are written in. */ + locale?: string; +} + +/** + * Use case for registering an installation of the app for notifications. + * + * Called at every launch, not only the first: the token is the app's address + * and can be reissued by the platform at any time, and re-registering is also + * what keeps `lastSeenAt` honest enough to drop installations that are gone. + */ +export class RegisterDeviceUseCase { + /** + * Creates a new instance of RegisterDeviceUseCase. + * + * @param deviceTokenRepository - Where registrations are stored + */ + constructor( + private readonly deviceTokenRepository: IDeviceTokenRepository, + ) {} + + /** + * Records the device against the calling account. + * + * Idempotent, and a move rather than a duplicate when the same phone comes + * back under a different account - which is what a shared device, or a + * second account on the same phone, produces. + * + * @param input - The device announcing itself + */ + async execute(input: RegisterDeviceInput): Promise { + await this.deviceTokenRepository.upsert( + DeviceToken.create({ + token: input.token, + userId: input.currentUserId, + platform: input.platform, + appVersion: input.appVersion ?? null, + locale: input.locale ?? null, + }), + ); + } +} diff --git a/src/core/use-cases/device/unregister-device/index.ts b/src/core/use-cases/device/unregister-device/index.ts new file mode 100644 index 0000000..d5c54e3 --- /dev/null +++ b/src/core/use-cases/device/unregister-device/index.ts @@ -0,0 +1,6 @@ +/** + * This module exports the UnregisterDeviceUseCase, which stops notifications + * to one installation. + */ +export { UnregisterDeviceUseCase } from "./unregister-device.usecase"; +export type { UnregisterDeviceInput } from "./unregister-device.usecase"; diff --git a/src/core/use-cases/device/unregister-device/unregister-device.usecase.ts b/src/core/use-cases/device/unregister-device/unregister-device.usecase.ts new file mode 100644 index 0000000..c73dccb --- /dev/null +++ b/src/core/use-cases/device/unregister-device/unregister-device.usecase.ts @@ -0,0 +1,50 @@ +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; + +/** + * Input DTO for the UnregisterDeviceUseCase. + */ +export interface UnregisterDeviceInput { + currentUserId: string; + + /** The Expo push token to stop notifying. */ + token: string; +} + +/** + * Use case for stopping notifications to one installation. + * + * Called when somebody signs out, and whenever they turn notifications off. + * Signing out without this leaves the phone receiving somebody's notifications + * after they have left it - which is the whole reason the app must call it + * before it discards its session, not after. + */ +export class UnregisterDeviceUseCase { + /** + * Creates a new instance of UnregisterDeviceUseCase. + * + * @param deviceTokenRepository - Where registrations are stored + */ + constructor( + private readonly deviceTokenRepository: IDeviceTokenRepository, + ) {} + + /** + * Removes the device, if it belongs to the calling account. + * + * Scoped to the owner: a push token is not a secret - it travels through + * the app and its logs - so holding one must not be enough to silence + * somebody else's phone. + * + * Silent when there is nothing to remove. A sign-out that runs twice, or + * one that follows a token the platform has already rotated, is not a + * failure anybody can act on. + * + * @param input - The device being retired + */ + async execute(input: UnregisterDeviceInput): Promise { + await this.deviceTokenRepository.deleteByToken( + input.token, + input.currentUserId, + ); + } +} diff --git a/src/core/use-cases/notification/send-push/index.ts b/src/core/use-cases/notification/send-push/index.ts new file mode 100644 index 0000000..397253f --- /dev/null +++ b/src/core/use-cases/notification/send-push/index.ts @@ -0,0 +1,10 @@ +/** + * This module exports the SendPushNotificationUseCase, the second transport + * beside the realtime socket. + */ +export { SendPushNotificationUseCase } from "./send-push-notification.usecase"; +export type { SendPushNotificationInput } from "./send-push-notification.usecase"; +/** + * This module exports the push notification copy table. + */ +export { pushCopyFor } from "./push-copy"; diff --git a/src/core/use-cases/notification/send-push/push-copy.ts b/src/core/use-cases/notification/send-push/push-copy.ts new file mode 100644 index 0000000..fc701a3 --- /dev/null +++ b/src/core/use-cases/notification/send-push/push-copy.ts @@ -0,0 +1,134 @@ +import { NotificationType } from "@core/domain/enums"; + +/** The languages push copy is written in. */ +type PushLanguage = "tr" | "en"; + +/** One notification, as a title and a body. */ +export interface PushCopy { + title: string; + body: string; +} + +/** + * What each kind of notification says, per language. + * + * The handle is interpolated rather than baked in so the same table serves + * both languages, and the body is a whole sentence rather than a fragment: a + * lock screen shows it with no context around it. + * + * Nothing here quotes what anybody wrote. A push payload travels through + * Google's servers to reach the phone, and the one thing this platform + * promises not to hand over that way is content. + */ +const PUSH_COPY: Record< + PushLanguage, + Record PushCopy> +> = { + tr: { + [NotificationType.FOLLOW]: (handle) => ({ + title: "Yeni takipçi", + body: `@${handle} seni takip etmeye başladı.`, + }), + [NotificationType.NEW_POST]: (handle) => ({ + title: "Yeni gönderi", + body: `@${handle} yeni bir gönderi paylaştı.`, + }), + [NotificationType.COMMENT]: (handle) => ({ + title: "Yeni yorum", + body: `@${handle} gönderine yorum yaptı.`, + }), + [NotificationType.LIKE]: (handle) => ({ + title: "Yeni beğeni", + body: `@${handle} gönderini beğendi.`, + }), + [NotificationType.COMMENT_LIKE]: (handle) => ({ + title: "Yeni beğeni", + body: `@${handle} yorumunu beğendi.`, + }), + [NotificationType.COMMENT_REPLY]: (handle) => ({ + title: "Yeni yanıt", + body: `@${handle} yorumuna yanıt verdi.`, + }), + [NotificationType.QUOTE]: (handle) => ({ + title: "Alıntı", + body: `@${handle} gönderini alıntıladı.`, + }), + [NotificationType.MENTION]: (handle) => ({ + title: "Senden bahsedildi", + body: `@${handle} bir gönderide senden bahsetti.`, + }), + [NotificationType.MEDIA_REJECTED]: () => ({ + title: "Medya reddedildi", + body: "Yüklediğin bir dosya kurallara takıldı.", + }), + }, + en: { + [NotificationType.FOLLOW]: (handle) => ({ + title: "New follower", + body: `@${handle} started following you.`, + }), + [NotificationType.NEW_POST]: (handle) => ({ + title: "New post", + body: `@${handle} shared a new post.`, + }), + [NotificationType.COMMENT]: (handle) => ({ + title: "New comment", + body: `@${handle} commented on your post.`, + }), + [NotificationType.LIKE]: (handle) => ({ + title: "New like", + body: `@${handle} liked your post.`, + }), + [NotificationType.COMMENT_LIKE]: (handle) => ({ + title: "New like", + body: `@${handle} liked your comment.`, + }), + [NotificationType.COMMENT_REPLY]: (handle) => ({ + title: "New reply", + body: `@${handle} replied to your comment.`, + }), + [NotificationType.QUOTE]: (handle) => ({ + title: "Quoted", + body: `@${handle} quoted your post.`, + }), + [NotificationType.MENTION]: (handle) => ({ + title: "You were mentioned", + body: `@${handle} mentioned you in a post.`, + }), + [NotificationType.MEDIA_REJECTED]: () => ({ + title: "Media rejected", + body: "A file you uploaded did not pass moderation.", + }), + }, +}; + +/** + * Picks the language a device should be written to in. + * + * The device's own locale, not the profile's feed languages: a notification is + * read on a lock screen that is already in one language, and the two settings + * answer different questions. Anything that is not Turkish falls to English, + * which is what the rest of the platform does. + * + * @param locale - The BCP-47 tag the app registered, if any + * @returns The language to write in + */ +function languageFor(locale: string | null): PushLanguage { + return locale?.toLowerCase().startsWith("tr") ? "tr" : "en"; +} + +/** + * Writes one notification for one device. + * + * @param type - What happened + * @param handle - Who caused it, without the leading "@" + * @param locale - The device's locale + * @returns The title and body to show + */ +export function pushCopyFor( + type: NotificationType, + handle: string, + locale: string | null, +): PushCopy { + return PUSH_COPY[languageFor(locale)][type](handle); +} diff --git a/src/core/use-cases/notification/send-push/send-push-notification.usecase.ts b/src/core/use-cases/notification/send-push/send-push-notification.usecase.ts new file mode 100644 index 0000000..25ac8c8 --- /dev/null +++ b/src/core/use-cases/notification/send-push/send-push-notification.usecase.ts @@ -0,0 +1,131 @@ +import type { NotificationType } from "@core/domain/enums"; +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { PushMessage, PushPort } from "@core/ports/services/push.port"; +import { pushCopyFor } from "./push-copy"; + +/** + * A notification that has just been stored, as much of it as a push needs. + */ +export interface SendPushNotificationInput { + recipientId: string; + + issuerId: string; + + type: NotificationType; + + /** Ids the app needs to open the right screen. */ + postId?: string; + commentId?: string; + articleId?: string; + articleSlug?: string; +} + +/** + * Use case for putting a notification on a user's phones. + * + * The second transport, beside the socket. A socket only exists while the app + * is in the foreground - both mobile platforms close it the moment the app is + * backgrounded - so without this, a notification reaches a phone only if + * somebody happens to be looking at it. + * + * Nothing here is load-bearing. The notification is already stored and already + * on the socket by the time this runs; a failure costs a buzz, so every path + * out of here is quiet. + */ +export class SendPushNotificationUseCase { + /** + * Creates a new instance of SendPushNotificationUseCase. + * + * @param deviceTokenRepository - The recipient's registered devices + * @param userRepository - Used to name the issuer in the copy + * @param notificationRepository - Used for the badge count + * @param pushService - The wire + * @param logger - Records a failure nobody is waiting on + */ + constructor( + private readonly deviceTokenRepository: IDeviceTokenRepository, + private readonly userRepository: IUserRepository, + private readonly notificationRepository: INotificationRepository, + private readonly pushService: PushPort, + private readonly logger: LoggerPort, + ) {} + + /** + * Sends one notification to every device the recipient has registered. + * + * @param input - The notification that was just stored + * + * @remarks + * Dead tokens are deleted as they are reported. A token for an app that + * was uninstalled never becomes valid again, and left in the table it + * would be retried on every notification for the rest of the account's + * life - a cost that grows with exactly the users who left. + */ + async execute(input: SendPushNotificationInput): Promise { + try { + const devices = await this.deviceTokenRepository.findByUserId( + input.recipientId, + ); + + if (devices.length === 0) return; + + const issuer = await this.userRepository.findById(input.issuerId); + + // Without a handle there is nothing worth saying: "somebody did + // something" is the kind of notification people turn off. + if (!issuer) return; + + const badge = await this.notificationRepository.getUnreadCount( + input.recipientId, + ); + + const data = this.deepLinkData(input); + + const messages: PushMessage[] = devices.map((device) => ({ + to: device.token, + ...pushCopyFor(input.type, issuer.username, device.locale), + data, + badge, + })); + + const { invalidTokens } = await this.pushService.send(messages); + + if (invalidTokens.length > 0) { + await this.deviceTokenRepository.deleteByTokens(invalidTokens); + } + } catch (error: unknown) { + this.logger.error( + { err: error, recipientId: input.recipientId }, + "Failed to deliver a push notification", + ); + } + } + + /** + * The ids the app opens the right screen with. + * + * Ids and a type only. This payload leaves our infrastructure and passes + * through Google's on its way to the phone, so nothing anybody wrote goes + * in it - which is also why a direct message is not notified from here at + * all: its text is encrypted at rest and putting a preview in a push would + * quietly undo that. + * + * @param input - The notification being delivered + * @returns The payload, with absent ids omitted + */ + private deepLinkData( + input: SendPushNotificationInput, + ): Record { + const data: Record = { type: input.type }; + + if (input.postId) data.postId = input.postId; + if (input.commentId) data.commentId = input.commentId; + if (input.articleId) data.articleId = input.articleId; + if (input.articleSlug) data.articleSlug = input.articleSlug; + + return data; + } +} diff --git a/src/http/controllers/device.controller.ts b/src/http/controllers/device.controller.ts new file mode 100644 index 0000000..fda6272 --- /dev/null +++ b/src/http/controllers/device.controller.ts @@ -0,0 +1,65 @@ +import type { RegisterDeviceUseCase } from "@core/use-cases/device/register-device"; +import type { UnregisterDeviceUseCase } from "@core/use-cases/device/unregister-device"; +import type { + RegisterDeviceBody, + UnregisterDeviceBody, +} from "@typings/schemas/device/device.schema"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +/** + * Controller for the push registration endpoints. + */ +export class DeviceController { + /** + * Creates a new DeviceController instance. + * + * @param registerDeviceUseCase - Use case that records an installation + * @param unregisterDeviceUseCase - Use case that retires one + */ + constructor( + private readonly registerDeviceUseCase: RegisterDeviceUseCase, + private readonly unregisterDeviceUseCase: UnregisterDeviceUseCase, + ) {} + + /** + * Registers the calling installation for notifications. + * + * @param request - The request, carrying the push token + * @param reply - The reply to send + */ + async register( + request: FastifyRequest<{ Body: RegisterDeviceBody }>, + reply: FastifyReply, + ): Promise { + await this.registerDeviceUseCase.execute({ + currentUserId: request.user!.id, + ...request.body, + }); + + reply.status(200).send({ + data: { registered: true }, + meta: { timestamp: new Date().toISOString() }, + }); + } + + /** + * Stops notifying the calling installation. + * + * @param request - The request, carrying the push token + * @param reply - The reply to send + */ + async unregister( + request: FastifyRequest<{ Body: UnregisterDeviceBody }>, + reply: FastifyReply, + ): Promise { + await this.unregisterDeviceUseCase.execute({ + currentUserId: request.user!.id, + token: request.body.token, + }); + + reply.status(200).send({ + data: { registered: false }, + meta: { timestamp: new Date().toISOString() }, + }); + } +} diff --git a/src/http/plugins/custom/device-purge.plugin.ts b/src/http/plugins/custom/device-purge.plugin.ts new file mode 100644 index 0000000..6105501 --- /dev/null +++ b/src/http/plugins/custom/device-purge.plugin.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; +import fastifyPlugin from "fastify-plugin"; + +function devicePurgePlugin(fastify: FastifyInstance): void { + const devicePurgeScheduler = + fastify.diContainer.cradle.devicePurgeScheduler; + + fastify.addHook("onReady", () => { + devicePurgeScheduler.start(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "DevicePurge", + status: "Started", + config: { + cronExpression: fastify.config.DEVICE_PURGE_CRON, + retentionDays: fastify.config.DEVICE_RETENTION_DAYS, + }, + }, + "Device purge scheduler initialized.", + ); + }); + + fastify.addHook("onClose", () => { + devicePurgeScheduler.stop(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "DevicePurge", + status: "Stopped", + }, + "Device purge scheduler stopped safely.", + ); + }); +} + +export default fastifyPlugin(devicePurgePlugin, { + name: "device-purge-plugin", + dependencies: ["di-plugin", "prisma-plugin", "env-plugin"], +}); diff --git a/src/http/plugins/di/controllers.di.ts b/src/http/plugins/di/controllers.di.ts index d8b3881..622dea6 100644 --- a/src/http/plugins/di/controllers.di.ts +++ b/src/http/plugins/di/controllers.di.ts @@ -10,6 +10,7 @@ import { FollowUserController } from "@controllers/follow-user.controller"; import { BlockController } from "@controllers/block.controller"; import { ReportController } from "@controllers/report.controller"; import { MetaController } from "@controllers/meta.controller"; +import { DeviceController } from "@controllers/device.controller"; import { CommentController } from "@controllers/comment.controller"; import { BookmarkController } from "@controllers/bookmark.controller"; import { TrendController } from "@controllers/trend.controller"; @@ -58,6 +59,7 @@ export const controllersModule = { blockController: asClass(BlockController).singleton(), reportController: asClass(ReportController).singleton(), metaController: asClass(MetaController).singleton(), + deviceController: asClass(DeviceController).singleton(), notificationController: asClass(NotificationController).singleton(), postController: asClass(PostController).singleton(), commentController: asClass(CommentController).singleton(), diff --git a/src/http/plugins/di/external.di.ts b/src/http/plugins/di/external.di.ts index e98cf85..730b595 100644 --- a/src/http/plugins/di/external.di.ts +++ b/src/http/plugins/di/external.di.ts @@ -1,5 +1,9 @@ import { asClass, asFunction } from "awilix"; import { EmailService } from "@infrastructure/external/email.service"; +import { + ExpoPushService, + NoopPushService, +} from "@infrastructure/external/push/expo-push.service"; import { GithubAuthService } from "@infrastructure/external/github-auth.service"; import { GoogleAuthService } from "@infrastructure/external/google-auth.service"; import { S3StorageService } from "@infrastructure/external/s3-storage.service"; @@ -11,6 +15,20 @@ import { NoopModerationService } from "@infrastructure/external/moderation/noop- export const externalModule = { // --- Services --- storageService: asClass(S3StorageService).singleton(), + /** + * Push delivery. Disabled by default: a deployment with no Expo project + * registers devices and sends nothing, rather than logging a failed HTTP + * call for every notification. + */ + pushService: asFunction((config, logger) => { + if (!config.PUSH_ENABLED) return new NoopPushService(); + + return new ExpoPushService( + { accessToken: config.EXPO_ACCESS_TOKEN }, + logger, + ); + }).singleton(), + emailService: asFunction((config, logger) => { return new EmailService( { diff --git a/src/http/plugins/di/jobs.di.ts b/src/http/plugins/di/jobs.di.ts index 5361104..e32defa 100644 --- a/src/http/plugins/di/jobs.di.ts +++ b/src/http/plugins/di/jobs.di.ts @@ -13,6 +13,8 @@ import { MediaModerationJob } from "@infrastructure/jobs/media-moderation/media- import { MediaModerationScheduler } from "@infrastructure/jobs/media-moderation/media-moderation.scheduler"; import { ReportDigestJob } from "@infrastructure/jobs/report/report-digest.job"; import { ReportDigestScheduler } from "@infrastructure/jobs/report/report-digest.scheduler"; +import { DevicePurgeJob } from "@infrastructure/jobs/device/device-purge.job"; +import { DevicePurgeScheduler } from "@infrastructure/jobs/device/device-purge.scheduler"; import { ReportPurgeJob } from "@infrastructure/jobs/report/report-purge.job"; import { ReportPurgeScheduler } from "@infrastructure/jobs/report/report-purge.scheduler"; import { MessageRetentionJob } from "@infrastructure/jobs/message/message-retention.job"; @@ -28,6 +30,7 @@ export const jobsModule = { messageRetentionJob: asClass(MessageRetentionJob).singleton(), reportDigestJob: asClass(ReportDigestJob).singleton(), reportPurgeJob: asClass(ReportPurgeJob).singleton(), + devicePurgeJob: asClass(DevicePurgeJob).singleton(), // --- Schedulers --- userPurgeScheduler: asFunction((userPurgeJob, config, logger) => { @@ -113,6 +116,17 @@ export const jobsModule = { ); }).singleton(), + devicePurgeScheduler: asFunction((devicePurgeJob, config, logger) => { + return new DevicePurgeScheduler( + devicePurgeJob, + { + cronExpression: config.DEVICE_PURGE_CRON, + retentionDays: config.DEVICE_RETENTION_DAYS, + }, + logger, + ); + }).singleton(), + reportPurgeScheduler: asFunction((reportPurgeJob, config, logger) => { return new ReportPurgeScheduler( reportPurgeJob, diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index df82d28..de327a9 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -9,6 +9,7 @@ import { PrismaBlockRepository } from "@infrastructure/persistence/repositories/ import { PrismaNotificationRepository } from "@infrastructure/persistence/repositories/prisma-notification.repository"; import { PrismaDigestDeliveryRepository } from "@infrastructure/persistence/repositories/prisma-digest-delivery.repository"; import { PrismaReportRepository } from "@infrastructure/persistence/repositories/prisma-report.repository"; +import { PrismaDeviceTokenRepository } from "@infrastructure/persistence/repositories/prisma-device-token.repository"; import { PrismaReportDigestDeliveryRepository } from "@infrastructure/persistence/repositories/prisma-report-digest-delivery.repository"; import { PrismaUserInterestRepository } from "@infrastructure/persistence/repositories/prisma-user-interest.repository"; import { PrismaPostRepository } from "@infrastructure/persistence/repositories/prisma-post.repository"; @@ -139,6 +140,11 @@ export const persistenceModule = { * Report repository for the content moderation queue */ reportRepository: asClass(PrismaReportRepository).singleton(), + + /** + * Device token repository for push notification addresses + */ + deviceTokenRepository: asClass(PrismaDeviceTokenRepository).singleton(), reportDigestDeliveryRepository: asClass( PrismaReportDigestDeliveryRepository, ).singleton(), diff --git a/src/http/plugins/di/realtime.di.ts b/src/http/plugins/di/realtime.di.ts index 48f196d..e6ee5be 100644 --- a/src/http/plugins/di/realtime.di.ts +++ b/src/http/plugins/di/realtime.di.ts @@ -1,7 +1,8 @@ -import { asClass } from "awilix"; +import { asClass, asFunction } from "awilix"; import { RedisService } from "@infrastructure/realtime/redis/redis.service"; import { WebSocketManager } from "@infrastructure/realtime/websocket/websocket-manager"; import { FastifyRealtimeService } from "@infrastructure/realtime/fastify-realtime.service"; +import { PushNotifyingRealtimeService } from "@infrastructure/realtime/push-notifying-realtime.service"; import { RedisSeenPostsService } from "@infrastructure/realtime/redis/redis-seen-posts.service"; export const realtimeModule = { @@ -11,5 +12,24 @@ export const realtimeModule = { // it resolves `cacheService` by parameter name. seenPostsService: asClass(RedisSeenPostsService).singleton(), wsManager: asClass(WebSocketManager).singleton(), - realtimeService: asClass(FastifyRealtimeService).singleton(), + /** + * The socket transport on its own. Nothing resolves this directly; it is + * what `realtimeService` wraps. + */ + realtimeTransport: asClass(FastifyRealtimeService).singleton(), + + /** + * The socket transport with push behind it. + * + * Registered under the name every use case already asks for, so a + * notification reaches a backgrounded phone without a dozen call sites + * learning that push exists. + */ + realtimeService: asFunction( + (realtimeTransport, sendPushNotificationUseCase) => + new PushNotifyingRealtimeService( + realtimeTransport, + sendPushNotificationUseCase, + ), + ).singleton(), }; diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 73c5887..59cfb80 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -103,6 +103,10 @@ import { UploadMessageMediaUseCase } from "@core/use-cases/message/upload-messag import { CreateReportUseCase } from "@core/use-cases/report/create-report"; import { SendReportDigestUseCase } from "@core/use-cases/report/send-report-digest"; import { PurgeOldReportsUseCase } from "@core/use-cases/report/purge-old-reports"; +import { RegisterDeviceUseCase } from "@core/use-cases/device/register-device"; +import { UnregisterDeviceUseCase } from "@core/use-cases/device/unregister-device"; +import { PurgeStaleDevicesUseCase } from "@core/use-cases/device/purge-stale-devices"; +import { SendPushNotificationUseCase } from "@core/use-cases/notification/send-push"; import { REPORT_EXCERPT_LENGTH, REPORT_MAX_DETAILS, @@ -206,6 +210,29 @@ export const useCasesModule = { nativeAllowList: splitList(config.OAUTH_NATIVE_REDIRECT_ALLOWLIST), })).singleton(), + /** + /** + * Use case for registering an app installation for notifications + */ + registerDeviceUseCase: asClass(RegisterDeviceUseCase).singleton(), + + /** + * Use case for retiring one + */ + unregisterDeviceUseCase: asClass(UnregisterDeviceUseCase).singleton(), + + /** + * Use case for dropping installations that stopped announcing themselves + */ + purgeStaleDevicesUseCase: asClass(PurgeStaleDevicesUseCase).singleton(), + + /** + * Use case that puts a notification on a user's phones + */ + sendPushNotificationUseCase: asClass( + SendPushNotificationUseCase, + ).singleton(), + /** * Use case for reporting a post or a comment */ diff --git a/src/http/routes/device.routes.ts b/src/http/routes/device.routes.ts new file mode 100644 index 0000000..9f67b06 --- /dev/null +++ b/src/http/routes/device.routes.ts @@ -0,0 +1,62 @@ +/** + * Device routes module + * + * Where an app says which phone to notify, and where it says to stop. There is + * no read side: the client already knows its own token, and listing somebody's + * devices back to them is a login-history feature, not this one. + * + * `SENSITIVE` rather than `STANDARD`. Registration happens once per launch, + * so 5/min is far above what the app needs, and this endpoint writes a row + * that decides where notifications physically arrive. + * + * @author TDN Team + * @version 1.0.0 + */ + +import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; +import { + DeviceActionResponseSchema, + RegisterDeviceBodySchema, + UnregisterDeviceBodySchema, + type RegisterDeviceBody, + type UnregisterDeviceBody, +} from "@typings/schemas/device/device.schema"; +import type { FastifyInstance } from "fastify"; + +/** + * Sets up the device routes on the Fastify instance. + * + * @param fastify - The Fastify application instance + * @returns void + */ +export default function deviceRoutes(fastify: FastifyInstance): void { + const deviceController = fastify.diContainer.cradle.deviceController; + + fastify.post<{ Body: RegisterDeviceBody }>( + "/devices", + { + schema: { + body: RegisterDeviceBodySchema, + response: { 200: DeviceActionResponseSchema }, + tags: ["Device"], + }, + onRequest: [fastify.authenticate], + config: { rateLimit: RateLimitPolicies.SENSITIVE }, + }, + deviceController.register.bind(deviceController), + ); + + fastify.delete<{ Body: UnregisterDeviceBody }>( + "/devices", + { + schema: { + body: UnregisterDeviceBodySchema, + response: { 200: DeviceActionResponseSchema }, + tags: ["Device"], + }, + onRequest: [fastify.authenticate], + config: { rateLimit: RateLimitPolicies.SENSITIVE }, + }, + deviceController.unregister.bind(deviceController), + ); +} diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index ddc98f5..1c056d5 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -9,6 +9,8 @@ import type { FollowUserController } from "@services/follow-user.controller"; import type { BlockController } from "@controllers/block.controller"; import type { ReportController } from "@controllers/report.controller"; import type { MetaController } from "@controllers/meta.controller"; +import type { DeviceController } from "@controllers/device.controller"; +import type { DevicePurgeScheduler } from "@infrastructure/jobs/device/device-purge.scheduler"; import type { ReportDigestScheduler } from "@infrastructure/jobs/report/report-digest.scheduler"; import type { ReportPurgeScheduler } from "@infrastructure/jobs/report/report-purge.scheduler"; import type { WebSocketManager } from "@infrastructure/realtime/websocket/websocket-manager"; @@ -88,6 +90,12 @@ declare module "@fastify/awilix" { /** Controller for the client compatibility endpoint */ metaController: MetaController; + /** Controller for push notification registrations */ + deviceController: DeviceController; + + /** Scheduler that drops abandoned push registrations */ + devicePurgeScheduler: DevicePurgeScheduler; + /** Scheduler for the morning summary of open reports */ reportDigestScheduler: ReportDigestScheduler; diff --git a/src/http/types/schemas/device/device.schema.ts b/src/http/types/schemas/device/device.schema.ts new file mode 100644 index 0000000..37dc6d9 --- /dev/null +++ b/src/http/types/schemas/device/device.schema.ts @@ -0,0 +1,43 @@ +import { type Static, Type } from "@fastify/type-provider-typebox"; +import { DevicePlatform } from "@core/domain/enums"; + +/** + * An app installation announcing itself. + * + * Sent at every launch rather than only the first: the platform can reissue a + * token at any time, and re-registering is also what keeps the row from being + * swept as abandoned. + */ +export const RegisterDeviceBodySchema = Type.Object({ + token: Type.String({ minLength: 1, maxLength: 512 }), + platform: Type.Enum(DevicePlatform), + appVersion: Type.Optional(Type.String({ maxLength: 32 })), + locale: Type.Optional(Type.String({ maxLength: 16 })), +}); + +export type RegisterDeviceBody = Static; + +/** + * An installation being retired. + */ +export const UnregisterDeviceBodySchema = Type.Object({ + token: Type.String({ minLength: 1, maxLength: 512 }), +}); + +export type UnregisterDeviceBody = Static; + +/** + * What either call answers. + * + * Deliberately empty of detail. Whether a row was written, moved or already + * matched is not something the caller can act on, and "this token was + * registered to somebody else" is not something it should learn. + */ +export const DeviceActionResponseSchema = Type.Object({ + data: Type.Object({ + registered: Type.Boolean(), + }), + meta: Type.Object({ timestamp: Type.String({ format: "date-time" }) }), +}); + +export type DeviceActionResponse = Static; diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index e986fda..c537509 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -247,6 +247,19 @@ export const EnvSchema = Type.Object({ // response body rather than in a cookie - which is why the two lists are // separate rather than one list with a rule about schemes. OAUTH_NATIVE_REDIRECT_ALLOWLIST: Type.String({ default: "" }), + // --- Push notifications --- + // Disabled by default, which swaps in a push service that sends nothing - + // the way MODERATION_ENABLED=false does for image scanning. Devices still + // register; nothing is delivered until there is a project to deliver + // through. + PUSH_ENABLED: Type.Boolean({ default: false }), + EXPO_ACCESS_TOKEN: Type.String({ default: "" }), + // How long a device that has stopped announcing itself is kept. The app + // re-registers at every launch, so a row this old is an installation that + // is gone - and one HTTP call per notification for the rest of the + // account's life if it is not dropped. + DEVICE_RETENTION_DAYS: Type.Number({ default: 90, minimum: 1 }), + DEVICE_PURGE_CRON: Type.String({ default: "0 6 * * *" }), // --- Mobile clients --- // A web client is whatever was served this morning; an app version lives on diff --git a/src/infrastructure/external/push/expo-push.service.ts b/src/infrastructure/external/push/expo-push.service.ts new file mode 100644 index 0000000..3ff4889 --- /dev/null +++ b/src/infrastructure/external/push/expo-push.service.ts @@ -0,0 +1,163 @@ +import type { + PushMessage, + PushPort, + PushSendResult, +} from "@core/ports/services/push.port"; +import type { FastifyBaseLogger } from "fastify"; +import axios from "axios"; + +const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; + +/** + * Most messages Expo accepts in one request. + */ +const CHUNK_SIZE = 100; + +/** + * The ticket status Expo returns for a token that no longer exists. + * + * Anything else - a malformed message, a provider hiccup - is a problem with + * this send. This one is a problem with the token, and the only fix is to stop + * holding it. + */ +const DEVICE_NOT_REGISTERED = "DeviceNotRegistered"; + +interface ExpoTicket { + status: "ok" | "error"; + id?: string; + message?: string; + details?: { error?: string }; +} + +export interface ExpoPushConfig { + /** + * Access token for a project with push security enabled. + * + * Optional: Expo accepts unauthenticated sends for projects that have not + * turned that on, which is the state of a project nobody has configured + * yet. Sending it when it exists costs nothing and is what stops anybody + * who learns a token from notifying its owner. + */ + accessToken: string; +} + +/** + * Expo implementation of the push port. + * + * Expo rather than FCM directly: it owns the FCM credentials, and the APNs + * ones when iOS arrives, which is a meaningful amount of key handling this + * service then never does. The port is what keeps that reversible - a direct + * FCM adapter is a sibling of this file, not a rewrite. + */ +export class ExpoPushService implements PushPort { + /** + * @param config - Expo credentials + * @param logger - Where delivery failures are recorded + */ + constructor( + private readonly config: ExpoPushConfig, + private readonly logger: FastifyBaseLogger, + ) {} + + /** + * Sends notifications, in batches Expo will accept. + * + * Never throws. The caller is a fire-and-forget path behind a notification + * that is already stored and already on the socket; a provider being down + * is a buzz nobody gets, not a request anybody should see fail. + * + * @param messages - The notifications to deliver. + * @returns How many were accepted, and which tokens are dead. + */ + async send(messages: PushMessage[]): Promise { + const result: PushSendResult = { delivered: 0, invalidTokens: [] }; + + for (let start = 0; start < messages.length; start += CHUNK_SIZE) { + const chunk = messages.slice(start, start + CHUNK_SIZE); + + await this.sendChunk(chunk, result); + } + + return result; + } + + /** + * Hands one batch to Expo and folds the answer into the result. + * + * Tickets come back positionally, which is the only thing tying a rejected + * token to the message that carried it - Expo does not echo the token. + * + * @param chunk - The messages in this batch + * @param result - The accumulating result + */ + private async sendChunk( + chunk: PushMessage[], + result: PushSendResult, + ): Promise { + try { + const response = await axios.post<{ data?: ExpoTicket[] }>( + EXPO_PUSH_URL, + chunk, + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(this.config.accessToken + ? { + Authorization: `Bearer ${this.config.accessToken}`, + } + : {}), + }, + }, + ); + + const tickets = response.data?.data ?? []; + + tickets.forEach((ticket, index) => { + if (ticket.status === "ok") { + result.delivered++; + return; + } + + if (ticket.details?.error === DEVICE_NOT_REGISTERED) { + const message = chunk[index]; + if (message) result.invalidTokens.push(message.to); + return; + } + + this.logger.warn( + { error: ticket.message, detail: ticket.details?.error }, + "Expo refused a push notification", + ); + }); + } catch (error: unknown) { + this.logger.error( + { err: error, count: chunk.length }, + "Failed to hand a push batch to Expo", + ); + } + } +} + +/** + * A push service that does nothing, for environments with no project. + * + * The counterpart of `NoopModerationService`: tests and local development have + * no Expo project and no phones, and a stack of failed HTTP calls in the log + * teaches nobody anything. It is never a fallback for a provider that is down - + * that case is handled above, by not throwing. + */ +export class NoopPushService implements PushPort { + /** + * Reports everything as delivered, having sent nothing. + * + * @param messages - The notifications that would have been sent. + * @returns A clean result with no dead tokens. + */ + send(messages: PushMessage[]): Promise { + return Promise.resolve({ + delivered: messages.length, + invalidTokens: [], + }); + } +} diff --git a/src/infrastructure/jobs/device/device-purge.job.ts b/src/infrastructure/jobs/device/device-purge.job.ts new file mode 100644 index 0000000..be9ddd3 --- /dev/null +++ b/src/infrastructure/jobs/device/device-purge.job.ts @@ -0,0 +1,23 @@ +import type { PurgeStaleDevicesUseCase } from "@core/use-cases/device/purge-stale-devices"; + +/** + * Runs one sweep of installations that stopped announcing themselves. + */ +export class DevicePurgeJob { + /** + * @param purgeStaleDevicesUseCase - The use case that removes them + */ + constructor( + private readonly purgeStaleDevicesUseCase: PurgeStaleDevicesUseCase, + ) {} + + /** + * Executes the sweep. + * + * @param retentionDays - How long an unseen device is kept + * @returns How many registrations were removed + */ + async run(retentionDays: number): Promise { + return this.purgeStaleDevicesUseCase.execute(retentionDays); + } +} diff --git a/src/infrastructure/jobs/device/device-purge.scheduler.ts b/src/infrastructure/jobs/device/device-purge.scheduler.ts new file mode 100644 index 0000000..c7e87d4 --- /dev/null +++ b/src/infrastructure/jobs/device/device-purge.scheduler.ts @@ -0,0 +1,71 @@ +import type { FastifyBaseLogger } from "fastify"; +import cron, { type ScheduledTask } from "node-cron"; +import type { DevicePurgeJob } from "./device-purge.job"; + +export interface DevicePurgeSchedulerOptions { + cronExpression: string; + retentionDays: number; +} + +/** + * Drops abandoned push registrations on a cron schedule. + * + * Passes no timezone, like the other purges: which hour of the container's + * local day this runs in changes nothing. + */ +export class DevicePurgeScheduler { + private task?: ScheduledTask; + + /** + * @param job - The job to run on each tick + * @param options - Schedule and retention window + * @param logger - Fastify logger + */ + constructor( + private readonly job: DevicePurgeJob, + private readonly options: DevicePurgeSchedulerOptions, + private readonly logger: FastifyBaseLogger, + ) {} + + /** + * Starts the schedule. Calling it twice is a no-op. + */ + start(): void { + if (this.task) return; + + this.task = cron.schedule(this.options.cronExpression, () => { + void (async (): Promise => { + try { + const deletedCount = await this.job.run( + this.options.retentionDays, + ); + + this.logger.info( + { + job: "device-purge", + deletedCount, + cronExpression: this.options.cronExpression, + retentionDays: this.options.retentionDays, + }, + "Device purge completed successfully", + ); + } catch (error) { + this.logger.error( + { job: "device-purge", error }, + "Device purge failed", + ); + } + })(); + }); + + this.logger.info("Device Purge Scheduler initialized"); + } + + /** + * Stops the schedule. + */ + stop(): void { + if (!this.task) return; + this.task = undefined; + } +} diff --git a/src/infrastructure/persistence/mappers/device-token-prisma.mapper.ts b/src/infrastructure/persistence/mappers/device-token-prisma.mapper.ts new file mode 100644 index 0000000..ee2dc59 --- /dev/null +++ b/src/infrastructure/persistence/mappers/device-token-prisma.mapper.ts @@ -0,0 +1,56 @@ +import type { + DeviceToken as PrismaDeviceToken, + Prisma, +} from "@generated/prisma/client"; +import { DeviceToken } from "@core/domain/entities/device-token.entity"; +import type { DevicePlatform } from "@core/domain/enums"; + +/** + * Two-way mapper between the `device_tokens` table and the domain entity. + * + * No `toResponse`: a push token is an address for reaching a phone, not + * something the API hands back. The client already has its own. + */ +export class DeviceTokenPrismaMapper { + /** + * Maps a database row to the domain entity. + * + * @param row - The Prisma device token row + * @returns The instantiated DeviceToken domain entity + */ + public static toDomain(row: PrismaDeviceToken): DeviceToken { + return DeviceToken.with({ + id: row.id, + token: row.token, + userId: row.userId, + platform: row.platform as unknown as DevicePlatform, + appVersion: row.appVersion, + locale: row.locale, + lastSeenAt: row.lastSeenAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } + + /** + * The fields a registration writes, whether the row exists or not. + * + * Shared between both halves of the upsert so a device that re-registers + * under a new account cannot keep a stale platform or locale from the old + * one. + * + * @param device - The registration + * @returns The Prisma update input + */ + public static toPrismaUpdate( + device: DeviceToken, + ): Prisma.DeviceTokenUpdateInput { + return { + user: { connect: { id: device.userId } }, + platform: device.platform, + appVersion: device.appVersion, + locale: device.locale, + lastSeenAt: new Date(), + }; + } +} diff --git a/src/infrastructure/persistence/repositories/prisma-device-token.repository.ts b/src/infrastructure/persistence/repositories/prisma-device-token.repository.ts new file mode 100644 index 0000000..a037026 --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-device-token.repository.ts @@ -0,0 +1,99 @@ +import type { DeviceToken } from "@core/domain/entities/device-token.entity"; +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; +import { DeviceTokenPrismaMapper } from "@infrastructure/persistence/mappers/device-token-prisma.mapper"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; + +/** + * Prisma implementation of the device token repository. + */ +export class PrismaDeviceTokenRepository implements IDeviceTokenRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Records a device against an account, moving it if it belonged to + * somebody else. + * + * The upsert is keyed on the token alone, which is what performs the move: + * the update half connects the row to whoever is registering now. + * + * @param device - The registration to write. + * @returns The stored device. + */ + async upsert(device: DeviceToken): Promise { + const row = await this.prisma.deviceToken.upsert({ + where: { token: device.token }, + update: DeviceTokenPrismaMapper.toPrismaUpdate(device), + create: { + token: device.token, + userId: device.userId, + platform: device.platform, + appVersion: device.appVersion, + locale: device.locale, + }, + }); + + return DeviceTokenPrismaMapper.toDomain(row); + } + + /** + * Removes one device, if it belongs to the account asking. + * + * @param token - The push token to remove. + * @param userId - The account it must belong to. + * @returns True when a row was removed. + */ + async deleteByToken(token: string, userId: string): Promise { + const { count } = await this.prisma.deviceToken.deleteMany({ + where: { token, userId }, + }); + + return count > 0; + } + + /** + * Reads every device registered to an account. + * + * @param userId - The account to look up. + * @returns Its devices. + */ + async findByUserId(userId: string): Promise { + const rows = await this.prisma.deviceToken.findMany({ + where: { userId }, + }); + + return rows.map((row) => DeviceTokenPrismaMapper.toDomain(row)); + } + + /** + * Removes devices whose tokens the push service has rejected. + * + * @param tokens - The dead tokens. + * @returns How many rows were removed. + */ + async deleteByTokens(tokens: string[]): Promise { + if (tokens.length === 0) return 0; + + const { count } = await this.prisma.deviceToken.deleteMany({ + where: { token: { in: tokens } }, + }); + + return count; + } + + /** + * Removes devices that have not re-registered in a long time. + * + * @param cutoff - Devices last seen before this are removed. + * @returns How many rows were removed. + */ + async deleteStale(cutoff: Date): Promise { + const { count } = await this.prisma.deviceToken.deleteMany({ + where: { lastSeenAt: { lt: cutoff } }, + }); + + return count; + } +} diff --git a/src/infrastructure/realtime/push-notifying-realtime.service.ts b/src/infrastructure/realtime/push-notifying-realtime.service.ts new file mode 100644 index 0000000..5a4945f --- /dev/null +++ b/src/infrastructure/realtime/push-notifying-realtime.service.ts @@ -0,0 +1,82 @@ +import type { NotificationType } from "@core/domain/enums"; +import type { + RealtimeEventPayload, + RealtimeNotificationPayload, + RealtimePort, +} from "@core/ports/services/realtime.port"; +import type { SendPushNotificationUseCase } from "@core/use-cases/notification/send-push"; + +/** + * The event every user-facing notification is emitted under. + * + * Chat travels the same channel under its own names, and must not be pushed + * from here: a message's text is encrypted at rest, and putting a preview in a + * push payload would route it through Google's servers and undo that. + */ +const NOTIFICATION_EVENT = "new-notification"; + +/** + * Adds push delivery to the realtime channel. + * + * A decorator rather than an edit to a dozen use cases. Every notification in + * this codebase follows the same two lines - store the row, emit + * `new-notification` - so wrapping the emit is the one seam that catches all + * of them, including the ones written after this. The alternative was touching + * every call site and relying on whoever adds the thirteenth to remember. + * + * The socket is still the primary transport and is never held up: the push is + * dispatched fire-and-forget behind it, because a socket write is immediate + * and a push involves an HTTP round trip to a third party. + */ +export class PushNotifyingRealtimeService implements RealtimePort { + /** + * Creates a new instance of PushNotifyingRealtimeService. + * + * @param realtimeTransport - The socket service being wrapped + * @param sendPushNotificationUseCase - The second transport + */ + constructor( + private readonly realtimeTransport: RealtimePort, + private readonly sendPushNotificationUseCase: SendPushNotificationUseCase, + ) {} + + /** + * Emits an event to a user, and pushes it if it is a notification. + * + * @param userId - The recipient + * @param event - The event name + * @param payload - The event payload + */ + emitToUser( + userId: string, + event: string, + payload: RealtimeEventPayload, + ): void { + this.realtimeTransport.emitToUser(userId, event, payload); + + if (event !== NOTIFICATION_EVENT) return; + + const notification = payload as RealtimeNotificationPayload; + + // A notification always names who caused it and what kind it is. + // Anything reaching this event without them is not one, whatever the + // union says at compile time. + if (!notification.issuerId || !notification.type) return; + + // The use case swallows its own failures, so this catch should never + // fire. It is here because the alternative if it ever did - a rejected + // promise nobody is holding - is an unhandled rejection, and this path + // runs behind a socket write on every notification in the system. + void this.sendPushNotificationUseCase + .execute({ + recipientId: userId, + issuerId: notification.issuerId, + type: notification.type as NotificationType, + postId: notification.postId, + commentId: notification.commentId, + articleId: notification.articleId, + articleSlug: notification.articleSlug, + }) + .catch(() => undefined); + } +} diff --git a/tests/e2e/device/device.test.ts b/tests/e2e/device/device.test.ts new file mode 100644 index 0000000..ee587e1 --- /dev/null +++ b/tests/e2e/device/device.test.ts @@ -0,0 +1,146 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for push registration. + * + * Delivery itself needs an Expo project and a phone, and neither exists in + * CI - `PUSH_ENABLED` is off, so the send is a no-op. What is testable here is + * the part that decides *where* a notification would physically arrive, which + * is the part worth getting wrong only once. + */ +describe("Device registration", () => { + const ts = Date.now(); + const owner = { + email: `dev-a-${ts}@test.com`, + password: "password123", + username: `deva${ts}`, + }; + const other = { + email: `dev-b-${ts}@test.com`, + password: "password123", + username: `devb${ts}`, + }; + + let ownerToken = ""; + let otherToken = ""; + + const registerAndLogin = async (user: { + email: string; + password: string; + username: string; + }): Promise => { + await request({ + method: "POST", + url: "/auth/register", + payload: user, + }); + + const loggedIn = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + + return parseBody<{ data: { accessToken: string } }>(loggedIn).data + .accessToken; + }; + + beforeAll(async () => { + ownerToken = await registerAndLogin(owner); + otherToken = await registerAndLogin(other); + }); + + const pushToken = `ExponentPushToken[${ts}]`; + + it("should register a device", async () => { + const response = await authRequest(ownerToken, { + method: "POST", + url: "/devices", + payload: { + token: pushToken, + platform: "ANDROID", + appVersion: "1", + locale: "tr-TR", + }, + }); + + expect(response.statusCode).toBe(200); + expect( + parseBody<{ data: { registered: boolean } }>(response).data + .registered, + ).toBe(true); + }); + + it("should accept the same device registering again", async () => { + // The app re-registers at every launch; that has to be a refresh, not + // a duplicate and not an error. + const response = await authRequest(ownerToken, { + method: "POST", + url: "/devices", + payload: { token: pushToken, platform: "ANDROID" }, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should move a device that reappears under another account", async () => { + // A shared phone, or a second account on the same one. Anything other + // than a move leaves one person's notifications on another's screen. + const moved = await authRequest(otherToken, { + method: "POST", + url: "/devices", + payload: { token: pushToken, platform: "ANDROID" }, + }); + + expect(moved.statusCode).toBe(200); + + // The previous owner can no longer retire it - it is not theirs. + const staleUnregister = await authRequest(ownerToken, { + method: "DELETE", + url: "/devices", + payload: { token: pushToken }, + }); + + expect(staleUnregister.statusCode).toBe(200); + + // ...and the current owner still can. + const unregister = await authRequest(otherToken, { + method: "DELETE", + url: "/devices", + payload: { token: pushToken }, + }); + + expect(unregister.statusCode).toBe(200); + }); + + it("should reject an unknown platform", async () => { + const response = await authRequest(ownerToken, { + method: "POST", + url: "/devices", + payload: { token: "ExponentPushToken[x]", platform: "WINDOWS" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject an empty token", async () => { + const response = await authRequest(ownerToken, { + method: "POST", + url: "/devices", + payload: { token: "", platform: "ANDROID" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should require a session", async () => { + const response = await request({ + method: "POST", + url: "/devices", + payload: { token: "ExponentPushToken[y]", platform: "ANDROID" }, + }); + + expect(response.statusCode).toBe(401); + }); +}); diff --git a/tests/unit/core/use-cases/notification/send-push-notification.usecase.test.ts b/tests/unit/core/use-cases/notification/send-push-notification.usecase.test.ts new file mode 100644 index 0000000..5db8e9d --- /dev/null +++ b/tests/unit/core/use-cases/notification/send-push-notification.usecase.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SendPushNotificationUseCase } from "@core/use-cases/notification/send-push"; +import { DeviceToken } from "@core/domain/entities/device-token.entity"; +import { DevicePlatform, NotificationType } from "@core/domain/enums"; +import type { IDeviceTokenRepository } from "@core/ports/repositories/device-token.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { PushPort } from "@core/ports/services/push.port"; +import { buildUser } from "../../../helpers/mock-factories"; + +function buildDevice(token: string, locale: string | null = "en-GB") { + return DeviceToken.with({ + id: `device-${token}`, + token, + userId: "user-1", + platform: DevicePlatform.ANDROID, + locale, + lastSeenAt: new Date(), + }); +} + +describe("SendPushNotificationUseCase", () => { + let devices: Pick< + IDeviceTokenRepository, + "findByUserId" | "deleteByTokens" + >; + let users: Pick; + let notifications: Pick; + let push: Pick; + let logger: LoggerPort; + let useCase: SendPushNotificationUseCase; + + const input = { + recipientId: "user-1", + issuerId: "user-2", + type: NotificationType.FOLLOW, + }; + + beforeEach(() => { + devices = { + findByUserId: vi.fn().mockResolvedValue([buildDevice("tok-a")]), + deleteByTokens: vi.fn().mockResolvedValue(1), + }; + users = { + findById: vi + .fn() + .mockResolvedValue(buildUser({ username: "ada" })), + }; + notifications = { getUnreadCount: vi.fn().mockResolvedValue(4) }; + push = { + send: vi.fn().mockResolvedValue({ delivered: 1, invalidTokens: [] }), + }; + logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn() } as unknown as LoggerPort; + + useCase = new SendPushNotificationUseCase( + devices as IDeviceTokenRepository, + users as IUserRepository, + notifications as INotificationRepository, + push as PushPort, + logger, + ); + }); + + it("should address one message per registered device", async () => { + vi.mocked(devices.findByUserId).mockResolvedValue([ + buildDevice("tok-a"), + buildDevice("tok-b"), + ]); + + await useCase.execute(input); + + const messages = vi.mocked(push.send).mock.calls[0]![0]; + + expect(messages.map((m) => m.to)).toEqual(["tok-a", "tok-b"]); + expect(messages[0]!.badge).toBe(4); + }); + + it("should write in the language the device is set to", async () => { + vi.mocked(devices.findByUserId).mockResolvedValue([ + buildDevice("tr-device", "tr-TR"), + buildDevice("en-device", "en-US"), + ]); + + await useCase.execute(input); + + const [turkish, english] = vi.mocked(push.send).mock.calls[0]![0]; + + expect(turkish!.body).toBe("@ada seni takip etmeye başladı."); + expect(english!.body).toBe("@ada started following you."); + }); + + it("should carry ids for the deep link and nothing anybody wrote", async () => { + await useCase.execute({ + ...input, + type: NotificationType.COMMENT, + postId: "post-1", + commentId: "comment-1", + }); + + const message = vi.mocked(push.send).mock.calls[0]![0][0]!; + + expect(message.data).toEqual({ + type: NotificationType.COMMENT, + postId: "post-1", + commentId: "comment-1", + }); + // The payload travels through a third party to reach the phone, so it + // must never grow a field carrying content. + expect(Object.keys(message.data)).toHaveLength(3); + }); + + it("should send nothing when the recipient has no devices", async () => { + vi.mocked(devices.findByUserId).mockResolvedValue([]); + + await useCase.execute(input); + + expect(push.send).not.toHaveBeenCalled(); + expect(users.findById).not.toHaveBeenCalled(); + }); + + it("should send nothing when the issuer cannot be named", async () => { + vi.mocked(users.findById).mockResolvedValue(null); + + await useCase.execute(input); + + expect(push.send).not.toHaveBeenCalled(); + }); + + it("should delete the tokens the service rejects", async () => { + vi.mocked(push.send).mockResolvedValue({ + delivered: 0, + invalidTokens: ["tok-a"], + }); + + await useCase.execute(input); + + expect(devices.deleteByTokens).toHaveBeenCalledWith(["tok-a"]); + }); + + it("should not touch the table when every token was accepted", async () => { + await useCase.execute(input); + + expect(devices.deleteByTokens).not.toHaveBeenCalled(); + }); + + it("should swallow a failure rather than fail the caller", async () => { + // The notification is already stored and already on the socket; the + // caller is a fire-and-forget path with nothing to do about this. + vi.mocked(push.send).mockRejectedValue(new Error("expo down")); + + await expect(useCase.execute(input)).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/infrastructure/realtime/push-notifying-realtime.service.test.ts b/tests/unit/infrastructure/realtime/push-notifying-realtime.service.test.ts new file mode 100644 index 0000000..7a94fba --- /dev/null +++ b/tests/unit/infrastructure/realtime/push-notifying-realtime.service.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PushNotifyingRealtimeService } from "@infrastructure/realtime/push-notifying-realtime.service"; +import { NotificationType } from "@core/domain/enums"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { SendPushNotificationUseCase } from "@core/use-cases/notification/send-push"; + +describe("PushNotifyingRealtimeService", () => { + let transport: RealtimePort; + let push: Pick; + let service: PushNotifyingRealtimeService; + + const notification = { + type: NotificationType.LIKE, + issuerId: "user-2", + postId: "post-1", + }; + + beforeEach(() => { + transport = { emitToUser: vi.fn() }; + push = { execute: vi.fn().mockResolvedValue(undefined) }; + + service = new PushNotifyingRealtimeService( + transport, + push as SendPushNotificationUseCase, + ); + }); + + it("should always emit on the socket", () => { + service.emitToUser("user-1", "new-notification", notification); + + expect(transport.emitToUser).toHaveBeenCalledWith( + "user-1", + "new-notification", + notification, + ); + }); + + it("should push a notification event", () => { + service.emitToUser("user-1", "new-notification", notification); + + expect(push.execute).toHaveBeenCalledWith({ + recipientId: "user-1", + issuerId: "user-2", + type: NotificationType.LIKE, + postId: "post-1", + commentId: undefined, + articleId: undefined, + articleSlug: undefined, + }); + }); + + it("should never push a chat event", () => { + // Message text is encrypted at rest. A push payload passes through a + // third party on its way to the phone, so chat must not travel this + // way at all - not even truncated. + service.emitToUser("user-1", "message:new", { + conversationId: "conv-1", + messageId: "msg-1", + senderId: "user-2", + preview: "something private", + }); + + expect(transport.emitToUser).toHaveBeenCalled(); + expect(push.execute).not.toHaveBeenCalled(); + }); + + it("should ignore a notification event missing its issuer or type", () => { + service.emitToUser("user-1", "new-notification", { + type: "", + issuerId: "", + }); + + expect(push.execute).not.toHaveBeenCalled(); + }); + + it("should not let a push failure reach the caller or escape as an unhandled rejection", async () => { + vi.mocked(push.execute).mockRejectedValue(new Error("boom")); + + expect(() => + service.emitToUser("user-1", "new-notification", notification), + ).not.toThrow(); + + // The socket write is what mattered and it already happened. + expect(transport.emitToUser).toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + }); +});