Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
95 changes: 95 additions & 0 deletions docs/push-notifications.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions prisma/migrations/20260911000000_add_device_tokens/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
52 changes: 52 additions & 0 deletions prisma/models/device.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions prisma/models/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
11 changes: 11 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ 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";
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";
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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, {
Expand Down
74 changes: 74 additions & 0 deletions src/core/domain/entities/device-token.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions src/core/domain/enums/device-platform.enum.ts
Original file line number Diff line number Diff line change
@@ -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",
}
1 change: 1 addition & 0 deletions src/core/domain/enums/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading