diff --git a/db/migrations.ts b/db/migrations.ts index df34f91..744b88e 100644 --- a/db/migrations.ts +++ b/db/migrations.ts @@ -43,7 +43,7 @@ const tables: Record = { "constraint valid_username check (username ~* '^[a-z\\d\\-_]{2,38}$')", ], }, - "user_token": { + "token": { columns: [ "digest bytea not null unique", "user_id uuid not null", @@ -51,7 +51,7 @@ const tables: Record = { createdAtTimestamp, ], additionalStatements: [ - "create index team_data_type on user_token using hash ((data->'type'))", + "create index team_data_type on token using hash ((data->'type'))", ], additionalTableStatements: [ 'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade', @@ -100,6 +100,110 @@ const tables: Record = { 'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade', ], }, + "playlist": { + columns: [ + id, + "display_name text not null", + "team_id uuid not null", + ...timestamps, + ], + additionalTableStatements: [ + 'constraint fk_team foreign key(team_id) references "team"(id) on delete cascade', + ], + }, + "display": { + columns: [ + id, + "display_name text default null", + "team_id uuid not null", + "is_frozen boolean default false", + "is_blanked boolean default false", + "playlist_id uuid not null", + "current_song_index integer default 0", + "current_verse_index integer default 0", + ...timestamps, + ], + additionalTableStatements: [ + // TODO: index timestamps? + 'constraint fk_playlist foreign key(playlist_id) references "playlist"(id) on delete cascade', + ], + }, + "song": { + columns: [ + id, + "team_id uuid not null", + ...timestamps, + ], + additionalTableStatements: [ + // TODO: index timestamps? + 'constraint fk_team foreign key(team_id) references "team"(id) on delete cascade', + ], + }, + "verse": { + columns: [ + id, + "display_name text default null", + "content text not null default ''", + "song_id uuid not null", + ...timestamps, + ], + additionalTableStatements: [ + 'constraint fk_song foreign key(song_id) references "song"(id) on delete cascade', + ], + }, + "map": { + columns: [ + id, + "display_name text not null", + "song_id uuid not null", + ...timestamps, + ], + additionalTableStatements: [ + 'constraint fk_song foreign key(song_id) references "song"(id) on delete cascade', + ], + }, + "map_verse": { + columns: [ + "map_id uuid", + "verse_id uuid", + '"index" integer not null', + ...timestamps, + ], + additionalTableStatements: [ + // TODO: is there a way to enforce that both of these must have the same song_id? + 'constraint fk_map foreign key(map_id) references "map"(id) on delete cascade', + 'constraint fk_song foreign key(verse_id) references "verse"(id) on delete cascade', + ], + additionalStatements: [ + 'create index map_verse_idx on "map_verse" (map_id) include (verse_id)', + 'create index map_idx on "map_verse" (map_id)', + 'create index verse_idx on "map_verse" (verse_id)', + 'create unique index map_index_idx on "map_verse" (map_id) include ("index")', + ], + }, + "playlist_song": { + columns: [ + "playlist_id uuid", + "song_id uuid", + "map_id uuid", + '"index" integer not null', + ...timestamps, + ], + additionalTableStatements: [ + // TODO: is there a way to enforce that playlist and song have the same team_id? + // TODO: is there a way to enforce that map.song_id is the same as song.id? + // TODO: if the map is deleted, do we really want to cascade it across playlists? + 'constraint fk_playlist foreign key(playlist_id) references "playlist"(id) on delete cascade', + 'constraint fk_song foreign key(song_id) references "song"(id) on delete cascade', + 'constraint fk_map foreign key(map_id) references "map"(id) on delete cascade', + ], + additionalStatements: [ + 'create index playlist_song_idx on "playlist_song" (playlist_id) include (song_id)', + 'create index playlist_idx on "playlist_song" (playlist_id)', + 'create index song_idx on "playlist_song" (song_id)', + 'create unique index playlist_index_idx on "playlist_song" (playlist_id) include ("index")', + ], + }, }; const createExtensions = extensions.map((s) => @@ -128,11 +232,26 @@ ${(meta.additionalStatements || []).map((s) => `${s.trim()};`).join("\n")} `; }).map((s) => s.trim()).join("\n\n"); -const queryString = ` +const cleanupQuery = ` begin; ${dropTables} +commit; +`; + +console.log(cleanupQuery); + +try { + const setupResult = await queryArray(cleanupQuery); + console.debug(setupResult); +} catch (err) { + console.log("Failed to run migration cleanup query:", { ...err }); + throw err; +} + +const queryString = ` + ${createExtensions} ${createFunctions} @@ -153,16 +272,15 @@ try { } try { - console.debug( - await Promise.all([ - createNote({ content: "Hello, notes!" }), - createUser({ - username: "lytedev", - passwordDigest: - "$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW", - }), - ]), - ); + const [note, user] = await Promise.all([ + createNote({ userId: null, content: "Hello, notes!" }), + createUser({ + username: "lytedev", + passwordDigest: + "$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW", + }), + ]); + console.debug({ note, user }); } catch (err) { console.log("Failed to run seed database:", { ...err }); throw err; diff --git a/db/mod.ts b/db/mod.ts index f36aa50..015327c 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -2,6 +2,8 @@ import { Pool, PoolClient, PostgresError, + Transaction, + type TransactionOptions, } from "https://deno.land/x/postgres@v0.16.1/mod.ts"; import { type QueryArguments, @@ -13,12 +15,14 @@ import * as base64 from "$std/encoding/base64.ts"; import { log } from "@/log.ts"; import { - type Identifiable, + type Display, type Note, + type Playlist, type Team, - type Timestamped, + type TeamUser, type Token, type TokenDigest, + type Ungenerated, type User, } from "@/types.ts"; @@ -29,15 +33,53 @@ export { type QueryObjectResult }; const pool = new Pool(config.postgres.url, 3, true); -async function dbOp(op: (connection: PoolClient) => Promise) { - let result = null; +type QueryResult = { rows: T[] } | null; + +class NoRowsError extends Error { + result: QueryResult; + + constructor(result: QueryResult) { + const message = `No rows in query result: ${result}`; + super(message); + this.result = result; + } +} + +class TooManyRowsError extends Error { + result: QueryResult; + + constructor(result: QueryResult) { + const message = `Too many rows in query result: ${result}`; + super(message); + this.result = result; + } +} + +function someRows(result: QueryResult): T[] { + if (!result || result.rows.length < 1) { + throw new NoRowsError(result); + } else { + return result.rows; + } +} + +function singleRow(result: QueryResult): T { + if (!result || result.rows.length < 1) throw new NoRowsError(result); + else if (result.rows.length > 1) throw new TooManyRowsError(result); + else return result.rows[0]; +} + +export async function dbOp( + op: (connection: PoolClient) => Promise, +): Promise { + let result: T | null = null; let exception = null; try { const connection = await pool.connect(); try { result = await op(connection); } catch (err) { - log.error("Error querying database:", { ...err }); + log.error("Error querying database:", err, { ...err }); exception = err; } finally { connection.release(); @@ -47,37 +89,54 @@ async function dbOp(op: (connection: PoolClient) => Promise) { log.error("Error connecting to database:", err); } if (exception != null) throw exception; + if (result == null) { + throw "Database operation failed to properly load a result"; + } return result; } export async function queryObject( sql: string, args?: QueryArguments, + connection?: PoolClient | Transaction, ): Promise | null> { - return await dbOp(async (connection) => { + console.debug(`queryObject: ${sql}`); + if (!connection) { + return await dbOp(async (connection) => { + return await queryObject(sql, args, connection); + }); + } else { const result = await connection.queryObject({ camelcase: true, text: sql.trim(), args, }); - log.debug(result); + log.debug("queryObject Result:", result); return result; - }); + } } export async function queryArray( sql: string, args?: QueryArguments, + connection?: PoolClient, ): Promise | null> { - return await dbOp(async (connection) => - await connection.queryArray({ + console.debug(`queryArray: ${sql}`); + if (!connection) { + return await dbOp(async (connection) => { + return await queryArray(sql, args, connection); + }); + } else { + const result = await connection.queryArray({ text: sql.trim(), args, - }) - ); + }); + log.debug("queryArray Result:", result); + return result; + } } -export async function listNotes(): Promise { +export async function listNotes(): Promise<(Note & User)[]> { return someRows( await queryObject( 'select u.username as user_username, u.display_name as user_display_name, n.* from note n left join "user" u on u.id = n.user_id order by n.created_at desc', @@ -87,7 +146,7 @@ export async function listNotes(): Promise { export async function getNote( id: string | { id: string }, -): Promise { +): Promise { const idVal = typeof id == "object" ? id.id : id; log.debug("getNote id =", JSON.stringify(idVal)); return singleRow( @@ -98,8 +157,6 @@ export async function getNote( ); } -type Ungenerated = Omit; - export async function createNote( { content, userId }: Ungenerated, ): Promise { @@ -111,44 +168,137 @@ export async function createNote( ); } -export async function createUser( - { username, passwordDigest }: Ungenerated, -): Promise<[User | null, Team | null] | null> { - const result = singleRow( - await queryObject<{ teamId: string; userId: string }>( +export async function createTeamUser( + { teamId, userId, status }: TeamUser, + transaction?: Transaction, +): Promise { + return singleRow( + await queryObject( ` - with new_user as ( - insert into "user" (username, password_digest) - values ($username, $passwordDigest) - returning id as user_id - ), new_team as ( - insert into "team" (display_name) - values ($teamName) - returning id as team_id - ) - insert into "team_user" (user_id, team_id, status) - values ( - (select user_id from new_user), - (select team_id from new_team), - 'owner' - ) returning user_id, team_id - `, - { username, passwordDigest, teamName: `${username}'s First Team` }, + insert into "team_user" (user_id, team_id, status) + values ( + $userId, + $teamId, + $status + ) returning *`, + { userId, teamId, status }, + transaction, ), ); - if (!result) return null; - const { userId, teamId } = result; - return await Promise.all([ - getUser({ id: userId }), - getTeam({ id: teamId }), - ]); +} + +export async function createTeam( + data: { + team: Ungenerated; + creator?: User; + }, + transaction?: Transaction, +): Promise { + console.debug("createTeam tx:", transaction); + if (!transaction) { + return await wrapWithTransaction( + "createTeam", + (t) => createTeam(data, t), + ); + } else { + try { + const { team: { displayName }, creator } = data; + const team = singleRow( + await queryObject( + `insert into "team" (display_name) values ($displayName) returning *`, + { displayName }, + transaction, + ), + ); + if (creator) { + await createTeamUser( + { teamId: team.id, userId: creator.id, status: "owner" }, + transaction, + ); + } + return team; + } catch (e) { + console.error("Error creating team:", e); + throw e; + } + } +} + +export async function wrapWithTransaction( + transactionName: string, + callback: (transaction: Transaction) => Promise, + transactionOptions?: TransactionOptions, +): Promise { + const result = await dbOp(async (connection) => { + try { + const transaction = connection.createTransaction( + transactionName, + transactionOptions, + ); + try { + await transaction.begin(); + console.debug( + `started ${transactionName} tx with options ${ + JSON.stringify(transactionOptions) + }:`, + transaction, + ); + const result: T = await callback(transaction); + await transaction.commit(); + return result; + } catch (e) { + await transaction.rollback(); + console.error("Failed to complete transaction:", e); + throw e; + } + } catch (e) { + console.error("Failed to create transaction"); + throw e; + } + }); + if (!result) throw "Failed to finish transactional database operation"; + return result; +} + +export async function createUser( + data: Ungenerated, + transaction?: Transaction, +): Promise { + if (!transaction) { + return await wrapWithTransaction( + "createUser", + (t) => createUser(data, t), + ); + } else { + try { + const { username, passwordDigest } = data; + const user = singleRow( + await queryObject( + `insert into "user" (username, password_digest) + values ($username, $passwordDigest) + returning *`, + { username, passwordDigest }, + transaction, + ), + ); + await createTeam({ + team: { displayName: `${username}'s First Team` }, + creator: user, + }, transaction); + + return user; + } catch (e) { + console.error("Error creating user:", e); + throw e; + } + } } const TOKEN_SIZE = 32; export async function createToken( token: Omit, "digest">, -): Promise { +): Promise { const intermediateToken: Partial = { ...token }; if (!intermediateToken.bytes) { intermediateToken.bytes = new Uint8Array(TOKEN_SIZE); @@ -169,15 +319,14 @@ export async function createToken( const result = singleRow( await queryObject( ` - insert into "user_token" (digest, user_id, data) + insert into "token" (digest, user_id, data) values ($digest, $userId, $data) returning * `, intermediateToken, ), ); - if (result) return { ...intermediateToken, ...result }; - return null; + return { ...intermediateToken, ...result }; } export async function deleteToken( @@ -185,52 +334,63 @@ export async function deleteToken( ) { const digest = sha256(base64.decode(token)); return await queryObject( - ` - delete from user_token where digest = $1 - `, + `delete from "token" where digest = $1`, [digest], ); } -export async function getToken(token: TokenDigest): Promise { +export async function getToken(token: TokenDigest): Promise { const digest = sha256(base64.decode(token)); return singleRow( await queryObject( - ` - select * from user_token where digest = $1 - `, + `select * from "token" where digest = $1`, [digest], ), ); } export async function getUser( - { id, username }: Partial, -): Promise { - if (!id && !username) throw "getUser called without id or username"; - const column = id ? "id" : "username"; - return singleRow( - await queryObject( - `select * from "user" where "${column}" = $1`, - [id || username], - ), - ); + idOrUsername: { id: string } | { username: string } | string, +): Promise { + if (typeof idOrUsername == "string") { + try { + return singleRow( + await queryObject( + `select * from "user" where "id" = $1`, + [idOrUsername], + ), + ); + } catch (_) { + return singleRow( + await queryObject( + `select * from "user" where "username" = $1`, + [idOrUsername], + ), + ); + } + } else { + const column = "id" in idOrUsername ? "id" : "username"; + return singleRow( + await queryObject( + `select * from "user" where "${column}" = $1`, + [(idOrUsername as { id?: string; username?: string })[column]], + ), + ); + } } export async function getUserFromNonExpiredLoginToken( token: TokenDigest, -): Promise { +): Promise { // TODO: if the token has expired, return a specific error? const digest = sha256(base64.decode(token)); return singleRow( await queryObject( - ` - select u.* from "user_token" ut + `select u.* from "token" ut left join "user" u on u.id = ut.user_id where ut."digest" = $1 and ut."data"->>'type' = 'login' - and now() < (ut.created_at + '14 days'::interval) - `, + and now() < (ut.created_at + '14 days'::interval)`, [digest], ), ); @@ -238,7 +398,7 @@ export async function getUserFromNonExpiredLoginToken( export async function getTeam( { id }: Partial, -): Promise { +): Promise { return singleRow( await queryObject( `select * from "team" where "id" = $1`, @@ -247,19 +407,31 @@ export async function getTeam( ); } -function someRows(result: { rows: T[] } | null): T[] | null { - log.debug(result); - if (!result || result.rows.length < 1) return null; - else return result.rows; +export async function getUserTeams( + { id }: Partial, +): Promise { + return someRows( + await queryObject( + `select t.* from "team" t + left join "team_user" tu on t.id = tu.team_id + where tu."user_id" = $1`, + [id], + ), + ); +} +export async function getTeamUsers( + { id }: Partial, +): Promise { + return someRows( + await queryObject( + `select u.* from "user" u + left join "team_user" tu on u.id = tu.user_id + where tu."team_id" = $1`, + [id], + ), + ); } -function singleRow(result: { rows: T[] } | null): T | null { - if (!result || result.rows.length < 1) return null; - else if (result.rows.length > 1) { - log.error( - "This singleRow result brought back more than 1 row:", - result, - ); - return null; - } else return result.rows[0]; -} +// export async function createDisplay(display: Ungenerated, transaction?: Transaction) { +// display +// } diff --git a/fresh.gen.ts b/fresh.gen.ts index b023b8d..9a5ca9d 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -24,6 +24,8 @@ import * as $17 from "./routes/plain.ts"; import * as $18 from "./routes/register.tsx"; import * as $19 from "./routes/route-config-example.tsx"; import * as $20 from "./routes/search.tsx"; +import * as $21 from "./routes/team/[id].tsx"; +import * as $22 from "./routes/user/[id].tsx"; import * as $$0 from "./islands/Countdown.tsx"; import * as $$1 from "./islands/Counter.tsx"; @@ -50,6 +52,8 @@ const manifest = { "./routes/register.tsx": $18, "./routes/route-config-example.tsx": $19, "./routes/search.tsx": $20, + "./routes/team/[id].tsx": $21, + "./routes/user/[id].tsx": $22, }, islands: { "./islands/Countdown.tsx": $$0, diff --git a/import_map.json b/import_map.json index 8ac9a8f..11431d0 100644 --- a/import_map.json +++ b/import_map.json @@ -2,8 +2,8 @@ "imports": { "@/": "./", "$std/": "https://deno.land/std@0.158.0/", - "$freshbranch/": "https://raw.githubusercontent.com/lytedev/fresh/v1.1.2-df/", - "$fresh/": "../fresh/", + "$fresh/": "https://raw.githubusercontent.com/lytedev/fresh/v1.1.2-df/", + "$freshrel/": "../fresh/", "preact": "https://esm.sh/preact@10.11.0", "preact/": "https://esm.sh/preact@10.11.0/", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4", diff --git a/routes/_app.tsx b/routes/_app.tsx index 8252de2..7c54099 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,5 +1,4 @@ -import { type AppProps, Handlers } from "$fresh/server.ts"; -import { type PublicUser } from "@/types.ts"; +import { type AppProps } from "$fresh/server.ts"; import { type ContextState } from "@/types.ts"; const NAV_ITEM_CLASSES = @@ -53,7 +52,7 @@ export default function App(

LyricScreen

Notes - {contextState.user ? UserNavItems() : LoginNavItems()} + {contextState?.user ? UserNavItems() : LoginNavItems()}
diff --git a/routes/_middleware.tsx b/routes/_middleware.tsx index 99c2f61..66173c4 100644 --- a/routes/_middleware.tsx +++ b/routes/_middleware.tsx @@ -22,10 +22,12 @@ async function currentUser( const { lsauth } = getCookies(request.headers); log.debug("lsauth cookie:", lsauth); if (lsauth) { - const user = await getUserFromNonExpiredLoginToken(lsauth); - if (!user) hasBadAuthCookie = true; - else { - context.state.user = toPublicUser(user); + try { + context.state.user = toPublicUser( + await getUserFromNonExpiredLoginToken(lsauth), + ); + } catch (e) { + hasBadAuthCookie = true; } } const resp = await context.next(); diff --git a/routes/dashboard.tsx b/routes/dashboard.tsx index 48a8a9d..6e4e0ff 100644 --- a/routes/dashboard.tsx +++ b/routes/dashboard.tsx @@ -1,27 +1,49 @@ -// import { getToken, getUser } from "@/db/mod.ts"; -// import * as base64 from "$std/encoding/base64.ts"; +import { getUserTeams } from "@/db/mod.ts"; import { Handlers, PageProps } from "$fresh/server.ts"; -import { type ContextState } from "@/types.ts"; +import { type ContextState, type PublicUser, type Team } from "@/types.ts"; + +interface DashboardProps { + user: PublicUser; + teams: Team[]; +} export const handler: Handlers = { async GET(_request: Request, context) { - return await context.render(context.state.user); + if (context.state.user?.id) { + const teams = await getUserTeams(context.state.user) || []; + return await context.render({ user: context.state.user, teams }); + } + return await context.render(); }, }; -export default function Dashboard({ data }: PageProps) { +export default function Page( + { data }: PageProps, +) { if (data) { - return You(data); + return Dashboard(data); } else { return LoginRequired(); } } -function You(data: unknown) { +function Dashboard({ teams, user }: DashboardProps) { return ( -

- You are

{JSON.stringify(data)}
. -

+ <> +

+ Hello, {(user.displayName || user.username).trim()}! +

+

+ Which team are we working with today? +

+ + ); } diff --git a/routes/logout.tsx b/routes/logout.tsx index a4e74d3..725f7f8 100644 --- a/routes/logout.tsx +++ b/routes/logout.tsx @@ -1,6 +1,4 @@ import { Handlers } from "$fresh/server.ts"; -// import { getToken, getUser } from "@/db/mod.ts"; -// import * as base64 from "$std/encoding/base64.ts"; import { deleteCookie, getCookies } from "$std/http/cookie.ts"; import { deleteToken } from "@/db/mod.ts"; diff --git a/routes/register.tsx b/routes/register.tsx index 1771b3a..1ef1adf 100644 --- a/routes/register.tsx +++ b/routes/register.tsx @@ -2,11 +2,13 @@ import { Handlers, PageProps } from "$fresh/server.ts"; import { createUser, PostgresError } from "@/db/mod.ts"; import { hash } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; +type UserId = string; + interface RegistrationError { message: string; } -export const handler: Handlers = { +export const handler: Handlers = { async POST(request, context) { const formData = (await request.formData()); const username = formData.get("username"); @@ -20,14 +22,10 @@ export const handler: Handlers = { } const passwordDigest = await hash(password.toString()); try { - const result = await createUser({ + const user = await createUser({ username: username.toString(), passwordDigest, }); - console.debug(result); - if (!result) throw "insert failed"; - const [user, _team] = result; - if (!user) throw "insert failed"; return await context.render(user.id); } catch (err) { if ( @@ -52,7 +50,7 @@ export const handler: Handlers = { }; export default function Register( - { data: userId }: PageProps, + { data: userId }: PageProps, ) { if (typeof userId == "string") { return RegistrationSuccessful(userId); @@ -61,7 +59,7 @@ export default function Register( } } -function RegistrationSuccessful(_userId: UserID) { +function RegistrationSuccessful(_userId: UserId) { return (

You're all signed up! Let's go log in! diff --git a/routes/team/[id].tsx b/routes/team/[id].tsx new file mode 100644 index 0000000..2a0bf99 --- /dev/null +++ b/routes/team/[id].tsx @@ -0,0 +1,46 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { getTeam, getTeamUsers } from "@/db/mod.ts"; +import { type Team, type User } from "@/types.ts"; + +interface TeamPageProps { + team: Team; + users: User[]; +} + +export const handler: Handlers = { + async GET(request, context) { + const { id } = context.params; + console.debug({ request, context }); + try { + const team = await getTeam({ id }); + const users = await getTeamUsers(team) || []; + return await context.render({ team, users }); + } catch (e) { + console.error(`Error handling team page for ID '${id}'`, e); + return await context.renderNotFound(); + } + }, +}; + +export default function Team( + { data: { team: { createdAt, displayName }, users } }: PageProps< + TeamPageProps + >, +) { + return ( + <> + Back to dashboard +

{displayName} - created {createdAt.toLocaleString()}

+

Team Members

+ + + ); +} diff --git a/routes/user/[id].tsx b/routes/user/[id].tsx new file mode 100644 index 0000000..ad072ff --- /dev/null +++ b/routes/user/[id].tsx @@ -0,0 +1,30 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { getUser } from "@/db/mod.ts"; +import { type Team, type User } from "@/types.ts"; + +interface UserPageProps { + user: User; +} + +export const handler: Handlers = { + async GET(request, context) { + console.debug({ request, context }); + const user = await getUser({ id: context.params.id }); + if (!user) throw "unable to fetch from database"; + return await context.render({ user }); + }, +}; + +export default function Team( + { data: { user: { username, createdAt, displayName } } }: PageProps< + UserPageProps + >, +) { + return ( + <> +

+ {(displayName || username).trim()} - joined {createdAt.toLocaleString()} +

+ + ); +} diff --git a/twind.config.ts b/twind.config.ts index 6861b54..b860e9d 100644 --- a/twind.config.ts +++ b/twind.config.ts @@ -31,6 +31,7 @@ export default { "::-moz-focus-inner": { border: 0 }, "body input, body textarea": input, "body button, body input[type=submit]": button, + "body ul": apply`list-disc ml-4`, "body a": apply`rounded ${focusRing} text-blue(600 700(hover:&) dark:(400 300(hover:&))`, }), diff --git a/types.ts b/types.ts index 9adcaa3..eb8e179 100644 --- a/types.ts +++ b/types.ts @@ -48,7 +48,30 @@ export interface Token extends Created { /** 32 bytes base64-encoded */ export type TokenDigest = string; -export interface ContextState { +export interface ContextState extends Record { user?: PublicUser; something?: string; } + +export type TeamUserStatus = "invited" | "accepted" | "owner"; +export interface TeamUser { + userId: IdentifierFor; + teamId: IdentifierFor; + status: TeamUserStatus; +} + +export interface Playlist extends Identifiable, Timestamped { + displayName: string; + teamId: IdentifierFor; +} + +export interface Display extends Identifiable, Timestamped { + displayName: string; + teamId: IdentifierFor; + playlistId: IdentifierFor; + isFrozen: boolean; + isBlanked: boolean; + currentSongIndex: number; + currentVerseIndex: number; +} +export type Ungenerated = Omit;