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;