trails/packages/db/src/schema/journal.ts
Ullrich Schäfer 3bcb1fce7c
Add 6-digit login code for mobile authentication
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:56:42 +02:00

176 lines
6.4 KiB
TypeScript

import {
pgSchema,
text,
timestamp,
integer,
real,
jsonb,
customType,
} 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 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(),
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(),
});
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"),
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[]>(),
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(),
});