import { pgSchema, text, timestamp, integer, real, jsonb, boolean, customType, uniqueIndex, index, } 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 /follows/requests). // 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"), 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"; 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(), 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(), }); 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(), }); export const activities = journalSchema.table("activities", { id: text("id").primaryKey(), ownerId: text("owner_id") .notNull() .references(() => users.id), routeId: text("route_id").references(() => routes.id), name: text("name").notNull(), description: text("description").default(""), 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), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // --- 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(), }); export const syncConnections = journalSchema.table("sync_connections", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), provider: text("provider").notNull(), accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), providerUserId: text("provider_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); 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(), }); // 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 const follows = journalSchema.table("follows", { id: text("id").primaryKey(), followerId: text("follower_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), 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), 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), }));