trails/packages/db/src/schema/journal.ts
Ullrich Schäfer 5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00

233 lines
9.5 KiB
TypeScript

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<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"),
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),
}));