sync_pushes tracks outbound route pushes to providers, keyed by (user_id, route_id, route_version, provider). Successful rows hold the remote_id; failed rows hold the error and can be retried in place. The unique index makes idempotent push trivial. granted_scopes records the OAuth scope set we requested at exchangeCode time. Wahoo doesn't return a scope field in token responses and grants scopes all-or-nothing, so the requested set is the source of truth. Defaults to an empty array, which means any pre-existing connection will be flagged as scope-mismatched on the first routes_write push — the intended UX for the slice 3 re-auth flow. Adjusts tasks 2.3/2.4 in the spec to match repo reality: this project runs `drizzle-kit push --force` schema-first, with no checked-in migrations directory. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
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 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<ProfileVisibility>().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<string[]>(),
|
|
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<number[]>(),
|
|
tags: jsonb("tags").$type<string[]>(),
|
|
plannerState: bytea("planner_state"),
|
|
visibility: text("visibility").$type<Visibility>().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<string[]>(),
|
|
participants: jsonb("participants").$type<string[]>(),
|
|
visibility: text("visibility").$type<Visibility>().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"),
|
|
// Scopes the user actually granted at OAuth time. Wahoo doesn't return a
|
|
// scope field in token responses and grants scopes all-or-nothing, so we
|
|
// record the requested scope set on exchangeCode. Used to detect when an
|
|
// existing connection predates a scope upgrade (e.g. routes_write) and
|
|
// needs a re-auth before a new push call can succeed.
|
|
grantedScopes: text("granted_scopes")
|
|
.array()
|
|
.notNull()
|
|
.default(sql`ARRAY[]::text[]`),
|
|
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(),
|
|
});
|
|
|
|
// Tracks outbound route pushes to external providers (Wahoo today). One row
|
|
// per (user, route, version, provider) — push idempotency lives here. A row
|
|
// with `pushedAt` set means we have a confirmed remote_id and won't re-call
|
|
// the provider; a row with `error` set and no `pushedAt` is a failed attempt
|
|
// that can be retried in place.
|
|
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" }),
|
|
routeVersion: integer("route_version").notNull(),
|
|
provider: text("provider").notNull(),
|
|
externalId: text("external_id").notNull(),
|
|
remoteId: text("remote_id"),
|
|
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) => ({
|
|
userRouteVersionProviderUnique: uniqueIndex("sync_pushes_user_route_version_provider_unique").on(
|
|
t.userId,
|
|
t.routeId,
|
|
t.routeVersion,
|
|
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 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),
|
|
}));
|
|
|
|
// 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<NotificationType>().notNull(),
|
|
actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
|
subjectId: text("subject_id"),
|
|
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
|
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`),
|
|
}));
|