1dfa2c0a04
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 lines
3.1 KiB
TypeScript
66 lines
3.1 KiB
TypeScript
import {
|
|
pgTable,
|
|
uuid,
|
|
text,
|
|
boolean,
|
|
timestamp,
|
|
integer,
|
|
} from "drizzle-orm/pg-core";
|
|
|
|
export const users = pgTable("users", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
displayName: text("display_name").notNull(),
|
|
email: text("email").notNull().unique(),
|
|
isAdmin: boolean("is_admin").notNull().default(false),
|
|
watchingNowTvSlots: integer("watching_now_tv_slots").notNull().default(5),
|
|
watchingNowMovieSlots: integer("watching_now_movie_slots").notNull().default(10),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
// A linked Plex account.
|
|
//
|
|
// Linking does two things at once, which is why it is worth a table of its own
|
|
// rather than another row in user_identities: it grants the person access to
|
|
// the Plex libraries, and it makes their Plex watchlist readable as demand.
|
|
// Neither is possible without knowing which Plex account belongs to which user.
|
|
//
|
|
// NO TOKEN IS STORED. The link is proved by the PIN flow -- the user signs in
|
|
// at plex.tv, we exchange the PIN for a token, ask Plex who it belongs to, and
|
|
// then throw the token away. Reading their watchlist needs the OWNER's token
|
|
// and the account uuid, both of which we already have, so keeping a second
|
|
// person's credential would buy nothing and be one more thing to leak.
|
|
export const plexAccounts = pgTable("plex_accounts", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
|
|
// Plex's numeric account id, and the uuid the community API keys watchlists on.
|
|
plexUserId: text("plex_user_id").notNull().unique(),
|
|
plexUuid: text("plex_uuid"),
|
|
plexUsername: text("plex_username").notNull(),
|
|
plexEmail: text("plex_email"),
|
|
// Does this account own the Plex server?
|
|
//
|
|
// The owner cannot be invited to their own libraries -- Plex refuses with
|
|
// "You cannot send an invitation to yourself." Without this column that
|
|
// refusal is indistinguishable from a share that genuinely failed, and the
|
|
// owner is told forever that their libraries did not share and to try again.
|
|
// They already have every library; there is nothing to grant.
|
|
isServerOwner: boolean("is_server_owner").notNull().default(false),
|
|
// When the libraries were shared, and which ones. Recorded so a failed or
|
|
// partial share is visible rather than being assumed to have worked.
|
|
// Both stay null for the owner, who was never invited to anything.
|
|
librariesSharedAt: timestamp("libraries_shared_at"),
|
|
sharedSectionIds: text("shared_section_ids").array(),
|
|
linkedAt: timestamp("linked_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
// Maps Authentik (or other OIDC) external identities to local users.
|
|
export const userIdentities = pgTable("user_identities", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
provider: text("provider").notNull(), // e.g. "authentik"
|
|
externalId: text("external_id").notNull(), // sub claim from OIDC
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
});
|