import { sql } from "drizzle-orm"; import { pgSchema, text, timestamp, integer, real, jsonb, boolean, customType, uniqueIndex, index, check, } from "drizzle-orm/pg-core"; const bytea = customType<{ data: Buffer }>({ dataType() { return "bytea"; }, }); const lineString = customType<{ data: string }>({ dataType() { return "geometry(LineString, 4326)"; }, }); export const journalSchema = pgSchema("journal"); export type ProfileVisibility = "public" | "private"; export const users = journalSchema.table("users", { id: text("id").primaryKey(), email: text("email").notNull().unique(), username: text("username").notNull().unique(), displayName: text("display_name"), bio: text("bio"), domain: text("domain").notNull(), // Profile visibility / lock setting. `public` means anyone can view // the profile and follows auto-accept. `private` is Mastodon-style // locked: the profile renders a stub for non-followers, and follows // require manual approval (Pending → Accepted via the Requests tab on // /notifications). // New users default to `private` to match trails.cool's privacy-first // content defaults; existing users were backfilled to `public` by an // earlier migration so behavior didn't change for them. profileVisibility: text("profile_visibility").$type().notNull().default("private"), // Federation signing keypair (spec: social-federation). The public key // is a JWK JSON string embedded in the actor object; the private key is // a JWK encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY (see // apps/journal app/lib/federation-keys.server.ts). NULL until generated — // at registration for new users, or by the one-shot // `backfill-user-keypairs` job for users predating federation. publicKey: text("public_key"), privateKeyEncrypted: text("private_key_encrypted"), termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }), termsVersion: text("terms_version"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const credentials = journalSchema.table("credentials", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), credentialId: bytea("credential_id").notNull(), publicKey: bytea("public_key").notNull(), counter: integer("counter").notNull().default(0), deviceType: text("device_type"), transports: jsonb("transports").$type(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const magicTokens = journalSchema.table("magic_tokens", { id: text("id").primaryKey(), email: text("email").notNull(), token: text("token").notNull().unique(), code: text("code"), purpose: text("purpose").notNull().default("login"), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); /** * Visibility for routes and activities. Stored as a plain text column so we * can extend without a migration. See spec `public-content-visibility`. * - private: only the owner can view * - unlisted: anyone with the direct URL can view; excluded from listings * - public: anyone can view; appears in listings and profiles */ export type Visibility = "private" | "unlisted" | "public"; /** * Distance-weighted surface/waytype breakdown (metres per category), derived * from BRouter waytags at Planner save or backfilled via Overpass. */ export type SurfaceBreakdown = { surface: Record; highway: Record; }; export const routes = journalSchema.table("routes", { id: text("id").primaryKey(), ownerId: text("owner_id") .notNull() .references(() => users.id), name: text("name").notNull(), description: text("description").default(""), gpx: text("gpx"), geom: lineString("geom"), routingProfile: text("routing_profile"), distance: real("distance"), elevationGain: real("elevation_gain"), elevationLoss: real("elevation_loss"), dayBreaks: jsonb("day_breaks").$type(), tags: jsonb("tags").$type(), surfaceBreakdown: jsonb("surface_breakdown").$type(), plannerState: bytea("planner_state"), visibility: text("visibility").$type().notNull().default("private"), /** * Content generated by the demo-activity-bot. Set to true only on insert; * never surfaced in user-facing update flows. Lets us exclude demo content * from analytics and wipe all bot output with a single DELETE. */ synthetic: boolean("synthetic").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ ownerUpdatedIdx: index("routes_owner_updated_idx").on(t.ownerId, t.updatedAt.desc()), })); export const routeVersions = journalSchema.table("route_versions", { id: text("id").primaryKey(), routeId: text("route_id") .notNull() .references(() => routes.id, { onDelete: "cascade" }), version: integer("version").notNull(), gpx: text("gpx").notNull(), createdBy: text("created_by").references(() => users.id), changeDescription: text("change_description"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); /** * Audience of an activity cached from a remote actor's outbox (spec: * social-federation, "Audience-aware feed filtering"). Local activities * are always 'public' here — their actual visibility lives in * `visibility`; this column only matters for remote rows, where * 'followers-only' content must reach only the local viewer whose * accepted follow brought it in. */ export type Audience = "public" | "followers-only"; /** * Sport / activity type. Nullable on activities (NULL = unspecified, which * is always valid). Stored as a plain text() column with a `.$type<>` guard, * matching the visibility/audience convention (no pgEnum). `SPORT_TYPES` is * the runtime source of truth for validation and iteration. */ export const SPORT_TYPES = [ "hike", "walk", "run", "ride", "gravel", "mtb", "ski", "other", ] as const; export type SportType = (typeof SPORT_TYPES)[number]; export const activities = journalSchema.table("activities", { id: text("id").primaryKey(), // Local author. NULL for activities ingested from a remote trails // actor's outbox, where `remoteActorIri` identifies the author — // exactly one of the two is set (check constraint below; same // pattern as follows.follower_id / follower_actor_iri). Resolves // the owner_id open question in social-federation design.md. ownerId: text("owner_id").references(() => users.id), routeId: text("route_id").references(() => routes.id), name: text("name").notNull(), description: text("description").default(""), // Sport / activity type (NULL = unspecified). See SPORT_TYPES above. sportType: text("sport_type").$type(), // Distance-weighted surface/waytype breakdown (backfilled via Overpass for // imported/uploaded activities). NULL until derived. surfaceBreakdown: jsonb("surface_breakdown").$type(), gpx: text("gpx"), geom: lineString("geom"), startedAt: timestamp("started_at", { withTimezone: true }), duration: integer("duration"), distance: real("distance"), elevationGain: real("elevation_gain"), elevationLoss: real("elevation_loss"), photos: jsonb("photos").$type(), participants: jsonb("participants").$type(), visibility: text("visibility").$type().notNull().default("private"), synthetic: boolean("synthetic").notNull().default(false), // Federation provenance (spec: social-federation). NULL for local // activities. For rows ingested from a remote trails actor's outbox: // `remoteOriginIri` is the activity's IRI on the origin instance // (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion), // `remoteActorIri` keys into `remote_actors`, and // `remotePublishedAt` carries the origin's publish time (feed sort // uses COALESCE(remote_published_at, created_at)). remoteOriginIri: text("remote_origin_iri").unique(), remoteActorIri: text("remote_actor_iri"), remotePublishedAt: timestamp("remote_published_at", { withTimezone: true }), audience: text("audience").$type().notNull().default("public"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ // Hot paths: listActivities (sort by started_at) and listPublicActivitiesForOwner. ownerStartedIdx: index("activities_owner_started_idx").on(t.ownerId, t.startedAt.desc()), ownerCreatedIdx: index("activities_owner_created_idx").on(t.ownerId, t.createdAt.desc()), // Feed join for remote rows (spec §8). remoteActorIdx: index("activities_remote_actor_idx").on(t.remoteActorIri), hasAuthorCheck: check( "activities_has_author_check", sql`(${t.ownerId} IS NOT NULL) <> (${t.remoteActorIri} IS NOT NULL)`, ), })); // --- OAuth2 PKCE (mobile app auth) --- export const oauthClients = journalSchema.table("oauth_clients", { clientId: text("client_id").primaryKey(), redirectUri: text("redirect_uri").notNull(), trusted: integer("trusted").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const oauthCodes = journalSchema.table("oauth_codes", { id: text("id").primaryKey(), code: text("code").notNull().unique(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), clientId: text("client_id") .notNull() .references(() => oauthClients.clientId, { onDelete: "cascade" }), codeChallenge: text("code_challenge").notNull(), codeChallengeMethod: text("code_challenge_method").notNull().default("S256"), redirectUri: text("redirect_uri").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const oauthTokens = journalSchema.table("oauth_tokens", { id: text("id").primaryKey(), accessToken: text("access_token").notNull().unique(), refreshToken: text("refresh_token").notNull().unique(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), clientId: text("client_id") .notNull() .references(() => oauthClients.clientId, { onDelete: "cascade" }), deviceName: text("device_name"), lastActiveAt: timestamp("last_active_at", { withTimezone: true }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), revokedAt: timestamp("revoked_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // External-service connections (OAuth, web-login, mobile-paired devices). // `credential_kind` discriminates the shape of `credentials` JSONB: // - 'oauth': { access_token, refresh_token, expires_at } // - 'web-login': { email, encrypted_password, session_jar } (Komoot, future) // - 'device': {} (Apple Health, future) // - 'public': {} credential-less public-profile connection (Komoot public mode) // Keep this list in sync with the CHECK constraint in // packages/db/migrations/0002_connected_services.sql. // See docs/adr/0001 and CONTEXT.md (Connected Services). export const connectedServices = journalSchema.table( "connected_services", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), provider: text("provider").notNull(), credentialKind: text("credential_kind").notNull(), credentials: jsonb("credentials").notNull(), // 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink' // when CredentialAdapter.refresh returns a permanent failure. status: text("status").notNull().default("active"), providerUserId: text("provider_user_id"), // OAuth-only. Promoted out of the credentials JSONB because feature gates // (e.g. routes_write check on push) read this on every call. grantedScopes: text("granted_scopes") .array() .notNull() .default(sql`ARRAY[]::text[]`), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on( t.userId, t.provider, ), }), ); export const syncImports = journalSchema.table("sync_imports", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), provider: text("provider").notNull(), externalWorkoutId: text("external_workout_id").notNull(), activityId: text("activity_id").references(() => activities.id, { onDelete: "set null" }), importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(), }); // Tracks outbound route pushes to external providers (Wahoo today). One row // per (user, route, provider) — a logical Wahoo route is per-route, not // per-version. `lastPushedVersion` records which local version is currently // reflected on the remote side; `remoteId` lets subsequent pushes PUT in // place. A row with `error` set and no `pushedAt` is a failed attempt that // can be retried (POST if `remoteId` is null, PUT otherwise). export const syncPushes = journalSchema.table( "sync_pushes", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), routeId: text("route_id") .notNull() .references(() => routes.id, { onDelete: "cascade" }), provider: text("provider").notNull(), externalId: text("external_id").notNull(), remoteId: text("remote_id"), lastPushedVersion: integer("last_pushed_version"), pushedAt: timestamp("pushed_at", { withTimezone: true }), error: text("error"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ userRouteProviderUnique: uniqueIndex("sync_pushes_user_route_provider_unique").on( t.userId, t.routeId, t.provider, ), }), ); // Social follow relation. Always originates from a local user (`followerId`). // The followed side is keyed by an actor IRI for federation forward-compat — // today every IRI is local (`https://{DOMAIN}/users/{username}`); future // `social-federation` change extends this to remote IRIs without migration. // `followedUserId` is denormalized for fast local joins; populated for every // row in this change. `acceptedAt` is always set today (auto-accept for // public local profiles); the column stays nullable so federation's Pending // state lands cleanly. export type ImportBatchStatus = "pending" | "running" | "completed" | "failed"; export const importBatches = journalSchema.table("import_batches", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), connectionId: text("connection_id") .notNull() .references(() => connectedServices.id, { onDelete: "cascade" }), provider: text("provider").notNull(), status: text("status").$type().notNull().default("pending"), totalFound: integer("total_found").notNull().default(0), importedCount: integer("imported_count").notNull().default(0), duplicateCount: integer("duplicate_count").notNull().default(0), errorMessage: text("error_message"), // Ranged backfill requests (Garmin): the activity time window this // batch asked the provider to re-deliver. NULL for pick-list style // imports (komoot bulk) that aren't range-shaped. rangeStart: timestamp("range_start", { withTimezone: true }), rangeEnd: timestamp("range_end", { withTimezone: true }), startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), completedAt: timestamp("completed_at", { withTimezone: true }), }); export const follows = journalSchema.table("follows", { id: text("id").primaryKey(), // Local follower. NULL for inbound federated follows (a remote actor // following a local user), where `followerActorIri` identifies the // follower instead. Exactly one of the two is set (check constraint). followerId: text("follower_id").references(() => users.id, { onDelete: "cascade" }), // Remote follower's actor IRI (spec: social-federation, inbound // Follow). NULL for local-origin follows. followerActorIri: text("follower_actor_iri"), followedActorIri: text("followed_actor_iri").notNull(), followedUserId: text("followed_user_id").references(() => users.id, { onDelete: "cascade" }), acceptedAt: timestamp("accepted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ followerActorUnique: uniqueIndex("follows_follower_actor_unique").on(t.followerId, t.followedActorIri), // Dedupe inbound remote follows: one row per (remote follower, local // followed user). Partial — local-origin rows have a NULL IRI here. remoteFollowerUnique: uniqueIndex("follows_remote_follower_unique") .on(t.followerActorIri, t.followedUserId) .where(sql`${t.followerActorIri} IS NOT NULL`), followerCreatedIdx: index("follows_follower_created_idx").on(t.followerId, t.createdAt.desc()), followedActorIdx: index("follows_followed_actor_idx").on(t.followedActorIri), followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId), hasFollowerCheck: check( "follows_has_follower_check", sql`(${t.followerId} IS NOT NULL) <> (${t.followerActorIri} IS NOT NULL)`, ), })); // Fedify KvStore backing table (inbox replay protection, remote // document / key caches). Keys are JSON-serialized KvKey arrays; // values arbitrary JSON. Expired rows are filtered at read time and // swept by the `federation-kv-sweep` job. export const federationKv = journalSchema.table("federation_kv", { key: text("key").primaryKey(), value: jsonb("value").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ expiresAtIdx: index("federation_kv_expires_at_idx").on(t.expiresAt), })); // Inbound-activity replay defense (spec: federation-operations "Inbound // replay defense"). One row per processed inbound activity IRI; inbox // handlers insert-or-drop before side effects so a redelivered activity // is a no-op. Rows older than 30 days are swept by the // `federation-dedup-sweep` job — HTTP-signature date freshness already // rejects older replays — so this never grows unbounded. Create(Note) // keeps its own idempotency via activities.remote_origin_iri; this table // covers the follow-graph activities (Follow/Undo/Accept/Reject). export const federationProcessedActivities = journalSchema.table("federation_processed_activities", { activityIri: text("activity_iri").primaryKey(), receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ receivedAtIdx: index("federation_processed_activities_received_at_idx").on(t.receivedAt), })); // Instance blocklist (spec: federation-operations "Instance blocklist"). // One row per blocked domain, matched exactly by host. Enforced at three // boundaries: inbox (activities silently dropped), delivery enqueue // (recipients filtered), and outbox poll / actor fetch (refused). v1 // management is a documented SQL insert/delete (see FEDERATION.md); the // table is the API a future admin UI can sit on. export const federationBlockedInstances = journalSchema.table("federation_blocked_instances", { domain: text("domain").primaryKey(), reason: text("reason"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // Cache of remote ActivityPub actors we interact with (spec: // social-federation). One row per actor IRI: display fields for feed // cards, inbox/outbox URLs for delivery and polling, the public key for // inbound HTTP-Signature verification, and `software` (the discovery // field) for the trails-to-trails outbound check. Refreshed during // outbox polls so feed renders never re-fetch the actor document. export const remoteActors = journalSchema.table("remote_actors", { actorIri: text("actor_iri").primaryKey(), displayName: text("display_name"), username: text("username"), domain: text("domain"), avatarUrl: text("avatar_url"), inboxUrl: text("inbox_url"), outboxUrl: text("outbox_url"), publicKey: text("public_key"), software: text("software"), lastPolledAt: timestamp("last_polled_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // Notifications. Each row is a single event the recipient should be // informed about. v1 types: follow_request_received, follow_request_approved, // follow_received, activity_published. The `payload` JSONB snapshots the // renderer-friendly fields at create time so future mobile push / email // renderers can render without a fresh DB lookup; `payloadVersion` lets // us evolve per-type payload shapes additively. See spec: notifications. export type NotificationType = | "follow_request_received" | "follow_request_approved" | "follow_received" | "activity_published"; export const notifications = journalSchema.table("notifications", { id: text("id").primaryKey(), recipientUserId: text("recipient_user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), type: text("type").$type().notNull(), actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }), subjectId: text("subject_id"), payload: jsonb("payload").$type>(), payloadVersion: integer("payload_version").notNull().default(1), readAt: timestamp("read_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ // Listing query: every load of /notifications joins on recipient + sorts by created_at desc. recipientCreatedIdx: index("notifications_recipient_created_idx").on(t.recipientUserId, t.createdAt.desc()), // Hot path: countUnread for the navbar badge runs on every page nav. // Partial index keeps it tiny. recipientUnreadIdx: index("notifications_recipient_unread_idx") .on(t.recipientUserId, t.createdAt.desc()) .where(sql`${t.readAt} IS NULL`), // Fan-out idempotency: prevent double-insert of activity_published rows // when a pg-boss job retries. Soft uniqueness across (recipient, type, subject). recipientTypeSubjectUnique: uniqueIndex("notifications_recipient_type_subject_unique") .on(t.recipientUserId, t.type, t.subjectId) .where(sql`${t.subjectId} IS NOT NULL`), })); // Single-use JWT enforcement for callback tokens (planner → journal // save flow). Each token carries a `jti` claim; the verifier inserts // into this table on first use. A second attempt hits the PK conflict // and the verifier rejects the token. Rows are swept after `expires_at`. // See spec / planner-audit #2 Phase B. export const consumedJwtJti = journalSchema.table("consumed_jwt_jti", { jti: text("jti").primaryKey(), consumedAt: timestamp("consumed_at", { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), }, (t) => ({ // Sweep runs `DELETE WHERE expires_at < now()` on a daily schedule. expiresAtIdx: index("consumed_jwt_jti_expires_at_idx").on(t.expiresAt), })); // --------------------------------------------------------------------------- // Canonical row types — derive from the schema, never re-declare by hand. // API wire shapes live in @trails-cool/api; these are the database truth. // --------------------------------------------------------------------------- export type RouteRow = typeof routes.$inferSelect; export type RouteVersionRow = typeof routeVersions.$inferSelect; export type ActivityRow = typeof activities.$inferSelect; export type UserRow = typeof users.$inferSelect;