This commit is contained in:
Daniel Flanagan 2022-10-21 03:06:37 -05:00
parent 50769ed32f
commit 70f2acc21e
Signed by untrusted user: lytedev-divvy
GPG key ID: 6D69CEEE4ABBCD82
13 changed files with 541 additions and 128 deletions

View file

@ -43,7 +43,7 @@ const tables: Record<string, TableSpec> = {
"constraint valid_username check (username ~* '^[a-z\\d\\-_]{2,38}$')", "constraint valid_username check (username ~* '^[a-z\\d\\-_]{2,38}$')",
], ],
}, },
"user_token": { "token": {
columns: [ columns: [
"digest bytea not null unique", "digest bytea not null unique",
"user_id uuid not null", "user_id uuid not null",
@ -51,7 +51,7 @@ const tables: Record<string, TableSpec> = {
createdAtTimestamp, createdAtTimestamp,
], ],
additionalStatements: [ 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: [ additionalTableStatements: [
'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade', 'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade',
@ -100,6 +100,110 @@ const tables: Record<string, TableSpec> = {
'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade', '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) => const createExtensions = extensions.map((s) =>
@ -128,11 +232,26 @@ ${(meta.additionalStatements || []).map((s) => `${s.trim()};`).join("\n")}
`; `;
}).map((s) => s.trim()).join("\n\n"); }).map((s) => s.trim()).join("\n\n");
const queryString = ` const cleanupQuery = `
begin; begin;
${dropTables} ${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} ${createExtensions}
${createFunctions} ${createFunctions}
@ -153,16 +272,15 @@ try {
} }
try { try {
console.debug( const [note, user] = await Promise.all([
await Promise.all([ createNote({ userId: null, content: "Hello, notes!" }),
createNote({ content: "Hello, notes!" }),
createUser({ createUser({
username: "lytedev", username: "lytedev",
passwordDigest: passwordDigest:
"$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW", "$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW",
}), }),
]), ]);
); console.debug({ note, user });
} catch (err) { } catch (err) {
console.log("Failed to run seed database:", { ...err }); console.log("Failed to run seed database:", { ...err });
throw err; throw err;

326
db/mod.ts
View file

@ -2,6 +2,8 @@ import {
Pool, Pool,
PoolClient, PoolClient,
PostgresError, PostgresError,
Transaction,
type TransactionOptions,
} from "https://deno.land/x/postgres@v0.16.1/mod.ts"; } from "https://deno.land/x/postgres@v0.16.1/mod.ts";
import { import {
type QueryArguments, type QueryArguments,
@ -13,12 +15,14 @@ import * as base64 from "$std/encoding/base64.ts";
import { log } from "@/log.ts"; import { log } from "@/log.ts";
import { import {
type Identifiable, type Display,
type Note, type Note,
type Playlist,
type Team, type Team,
type Timestamped, type TeamUser,
type Token, type Token,
type TokenDigest, type TokenDigest,
type Ungenerated,
type User, type User,
} from "@/types.ts"; } from "@/types.ts";
@ -29,15 +33,53 @@ export { type QueryObjectResult };
const pool = new Pool(config.postgres.url, 3, true); const pool = new Pool(config.postgres.url, 3, true);
async function dbOp<T>(op: (connection: PoolClient) => Promise<T>) { type QueryResult<T> = { rows: T[] } | null;
let result = null;
class NoRowsError<T> extends Error {
result: QueryResult<T>;
constructor(result: QueryResult<T>) {
const message = `No rows in query result: ${result}`;
super(message);
this.result = result;
}
}
class TooManyRowsError<T> extends Error {
result: QueryResult<T>;
constructor(result: QueryResult<T>) {
const message = `Too many rows in query result: ${result}`;
super(message);
this.result = result;
}
}
function someRows<T>(result: QueryResult<T>): T[] {
if (!result || result.rows.length < 1) {
throw new NoRowsError(result);
} else {
return result.rows;
}
}
function singleRow<T>(result: QueryResult<T>): 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<T>(
op: (connection: PoolClient) => Promise<T>,
): Promise<T> {
let result: T | null = null;
let exception = null; let exception = null;
try { try {
const connection = await pool.connect(); const connection = await pool.connect();
try { try {
result = await op(connection); result = await op(connection);
} catch (err) { } catch (err) {
log.error("Error querying database:", { ...err }); log.error("Error querying database:", err, { ...err });
exception = err; exception = err;
} finally { } finally {
connection.release(); connection.release();
@ -47,37 +89,54 @@ async function dbOp<T>(op: (connection: PoolClient) => Promise<T>) {
log.error("Error connecting to database:", err); log.error("Error connecting to database:", err);
} }
if (exception != null) throw exception; if (exception != null) throw exception;
if (result == null) {
throw "Database operation failed to properly load a result";
}
return result; return result;
} }
export async function queryObject<T>( export async function queryObject<T>(
sql: string, sql: string,
args?: QueryArguments, args?: QueryArguments,
connection?: PoolClient | Transaction,
): Promise<QueryObjectResult<T> | null> { ): Promise<QueryObjectResult<T> | null> {
console.debug(`queryObject: ${sql}`);
if (!connection) {
return await dbOp(async (connection) => { return await dbOp(async (connection) => {
return await queryObject(sql, args, connection);
});
} else {
const result = await connection.queryObject<T>({ const result = await connection.queryObject<T>({
camelcase: true, camelcase: true,
text: sql.trim(), text: sql.trim(),
args, args,
}); });
log.debug(result); log.debug("queryObject Result:", result);
return result; return result;
}); }
} }
export async function queryArray<T extends []>( export async function queryArray<T extends []>(
sql: string, sql: string,
args?: QueryArguments, args?: QueryArguments,
connection?: PoolClient,
): Promise<QueryArrayResult<T> | null> { ): Promise<QueryArrayResult<T> | null> {
return await dbOp(async (connection) => console.debug(`queryArray: ${sql}`);
await connection.queryArray<T>({ if (!connection) {
return await dbOp(async (connection) => {
return await queryArray<T>(sql, args, connection);
});
} else {
const result = await connection.queryArray<T>({
text: sql.trim(), text: sql.trim(),
args, args,
}) });
); log.debug("queryArray Result:", result);
return result;
}
} }
export async function listNotes(): Promise<Note[] | null> { export async function listNotes(): Promise<(Note & User)[]> {
return someRows( return someRows(
await queryObject<Note & User>( await queryObject<Note & User>(
'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', '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<Note[] | null> {
export async function getNote( export async function getNote(
id: string | { id: string }, id: string | { id: string },
): Promise<Note | null> { ): Promise<Note> {
const idVal = typeof id == "object" ? id.id : id; const idVal = typeof id == "object" ? id.id : id;
log.debug("getNote id =", JSON.stringify(idVal)); log.debug("getNote id =", JSON.stringify(idVal));
return singleRow( return singleRow(
@ -98,8 +157,6 @@ export async function getNote(
); );
} }
type Ungenerated<T> = Omit<T, keyof Identifiable | keyof Timestamped>;
export async function createNote( export async function createNote(
{ content, userId }: Ungenerated<Note>, { content, userId }: Ungenerated<Note>,
): Promise<Note | null> { ): Promise<Note | null> {
@ -111,44 +168,137 @@ export async function createNote(
); );
} }
export async function createUser( export async function createTeamUser(
{ username, passwordDigest }: Ungenerated<User>, { teamId, userId, status }: TeamUser,
): Promise<[User | null, Team | null] | null> { transaction?: Transaction,
const result = singleRow( ): Promise<TeamUser | null> {
await queryObject<{ teamId: string; userId: string }>( return singleRow(
await queryObject<TeamUser>(
` `
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) insert into "team_user" (user_id, team_id, status)
values ( values (
(select user_id from new_user), $userId,
(select team_id from new_team), $teamId,
'owner' $status
) returning user_id, team_id ) returning *`,
`, { userId, teamId, status },
{ username, passwordDigest, teamName: `${username}'s First Team` }, transaction,
), ),
); );
if (!result) return null; }
const { userId, teamId } = result;
return await Promise.all([ export async function createTeam(
getUser({ id: userId }), data: {
getTeam({ id: teamId }), team: Ungenerated<Team>;
]); creator?: User;
},
transaction?: Transaction,
): Promise<Team> {
console.debug("createTeam tx:", transaction);
if (!transaction) {
return await wrapWithTransaction<Team>(
"createTeam",
(t) => createTeam(data, t),
);
} else {
try {
const { team: { displayName }, creator } = data;
const team = singleRow(
await queryObject<Team>(
`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<T>(
transactionName: string,
callback: (transaction: Transaction) => Promise<T>,
transactionOptions?: TransactionOptions,
): Promise<T> {
const result = await dbOp<T>(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<User>,
transaction?: Transaction,
): Promise<User> {
if (!transaction) {
return await wrapWithTransaction<User>(
"createUser",
(t) => createUser(data, t),
);
} else {
try {
const { username, passwordDigest } = data;
const user = singleRow(
await queryObject<User>(
`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; const TOKEN_SIZE = 32;
export async function createToken( export async function createToken(
token: Omit<Ungenerated<Token>, "digest">, token: Omit<Ungenerated<Token>, "digest">,
): Promise<Token | null> { ): Promise<Token> {
const intermediateToken: Partial<Token> = { ...token }; const intermediateToken: Partial<Token> = { ...token };
if (!intermediateToken.bytes) { if (!intermediateToken.bytes) {
intermediateToken.bytes = new Uint8Array(TOKEN_SIZE); intermediateToken.bytes = new Uint8Array(TOKEN_SIZE);
@ -169,15 +319,14 @@ export async function createToken(
const result = singleRow( const result = singleRow(
await queryObject<Token>( await queryObject<Token>(
` `
insert into "user_token" (digest, user_id, data) insert into "token" (digest, user_id, data)
values ($digest, $userId, $data) values ($digest, $userId, $data)
returning * returning *
`, `,
intermediateToken, intermediateToken,
), ),
); );
if (result) return { ...intermediateToken, ...result }; return { ...intermediateToken, ...result };
return null;
} }
export async function deleteToken( export async function deleteToken(
@ -185,52 +334,63 @@ export async function deleteToken(
) { ) {
const digest = sha256(base64.decode(token)); const digest = sha256(base64.decode(token));
return await queryObject( return await queryObject(
` `delete from "token" where digest = $1`,
delete from user_token where digest = $1
`,
[digest], [digest],
); );
} }
export async function getToken(token: TokenDigest): Promise<Token | null> { export async function getToken(token: TokenDigest): Promise<Token> {
const digest = sha256(base64.decode(token)); const digest = sha256(base64.decode(token));
return singleRow( return singleRow(
await queryObject( await queryObject(
` `select * from "token" where digest = $1`,
select * from user_token where digest = $1
`,
[digest], [digest],
), ),
); );
} }
export async function getUser( export async function getUser(
{ id, username }: Partial<User>, idOrUsername: { id: string } | { username: string } | string,
): Promise<User | null> { ): Promise<User> {
if (!id && !username) throw "getUser called without id or username"; if (typeof idOrUsername == "string") {
const column = id ? "id" : "username"; try {
return singleRow(
await queryObject<User>(
`select * from "user" where "id" = $1`,
[idOrUsername],
),
);
} catch (_) {
return singleRow(
await queryObject<User>(
`select * from "user" where "username" = $1`,
[idOrUsername],
),
);
}
} else {
const column = "id" in idOrUsername ? "id" : "username";
return singleRow( return singleRow(
await queryObject<User>( await queryObject<User>(
`select * from "user" where "${column}" = $1`, `select * from "user" where "${column}" = $1`,
[id || username], [(idOrUsername as { id?: string; username?: string })[column]],
), ),
); );
} }
}
export async function getUserFromNonExpiredLoginToken( export async function getUserFromNonExpiredLoginToken(
token: TokenDigest, token: TokenDigest,
): Promise<User | null> { ): Promise<User> {
// TODO: if the token has expired, return a specific error? // TODO: if the token has expired, return a specific error?
const digest = sha256(base64.decode(token)); const digest = sha256(base64.decode(token));
return singleRow( return singleRow(
await queryObject<User>( await queryObject<User>(
` `select u.* from "token" ut
select u.* from "user_token" ut
left join "user" u on u.id = ut.user_id left join "user" u on u.id = ut.user_id
where ut."digest" = $1 where ut."digest" = $1
and ut."data"->>'type' = 'login' and ut."data"->>'type' = 'login'
and now() < (ut.created_at + '14 days'::interval) and now() < (ut.created_at + '14 days'::interval)`,
`,
[digest], [digest],
), ),
); );
@ -238,7 +398,7 @@ export async function getUserFromNonExpiredLoginToken(
export async function getTeam( export async function getTeam(
{ id }: Partial<Team>, { id }: Partial<Team>,
): Promise<Team | null> { ): Promise<Team> {
return singleRow( return singleRow(
await queryObject<Team>( await queryObject<Team>(
`select * from "team" where "id" = $1`, `select * from "team" where "id" = $1`,
@ -247,19 +407,31 @@ export async function getTeam(
); );
} }
function someRows<T>(result: { rows: T[] } | null): T[] | null { export async function getUserTeams(
log.debug(result); { id }: Partial<User>,
if (!result || result.rows.length < 1) return null; ): Promise<Team[]> {
else return result.rows; return someRows(
await queryObject<Team>(
`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<Team>,
): Promise<User[]> {
return someRows(
await queryObject<User>(
`select u.* from "user" u
left join "team_user" tu on u.id = tu.user_id
where tu."team_id" = $1`,
[id],
),
);
} }
function singleRow<T>(result: { rows: T[] } | null): T | null { // export async function createDisplay(display: Ungenerated<Display>, transaction?: Transaction) {
if (!result || result.rows.length < 1) return null; // display
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];
}

View file

@ -24,6 +24,8 @@ import * as $17 from "./routes/plain.ts";
import * as $18 from "./routes/register.tsx"; import * as $18 from "./routes/register.tsx";
import * as $19 from "./routes/route-config-example.tsx"; import * as $19 from "./routes/route-config-example.tsx";
import * as $20 from "./routes/search.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 $$0 from "./islands/Countdown.tsx";
import * as $$1 from "./islands/Counter.tsx"; import * as $$1 from "./islands/Counter.tsx";
@ -50,6 +52,8 @@ const manifest = {
"./routes/register.tsx": $18, "./routes/register.tsx": $18,
"./routes/route-config-example.tsx": $19, "./routes/route-config-example.tsx": $19,
"./routes/search.tsx": $20, "./routes/search.tsx": $20,
"./routes/team/[id].tsx": $21,
"./routes/user/[id].tsx": $22,
}, },
islands: { islands: {
"./islands/Countdown.tsx": $$0, "./islands/Countdown.tsx": $$0,

View file

@ -2,8 +2,8 @@
"imports": { "imports": {
"@/": "./", "@/": "./",
"$std/": "https://deno.land/std@0.158.0/", "$std/": "https://deno.land/std@0.158.0/",
"$freshbranch/": "https://raw.githubusercontent.com/lytedev/fresh/v1.1.2-df/", "$fresh/": "https://raw.githubusercontent.com/lytedev/fresh/v1.1.2-df/",
"$fresh/": "../fresh/", "$freshrel/": "../fresh/",
"preact": "https://esm.sh/preact@10.11.0", "preact": "https://esm.sh/preact@10.11.0",
"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", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4",

View file

@ -1,5 +1,4 @@
import { type AppProps, Handlers } from "$fresh/server.ts"; import { type AppProps } from "$fresh/server.ts";
import { type PublicUser } from "@/types.ts";
import { type ContextState } from "@/types.ts"; import { type ContextState } from "@/types.ts";
const NAV_ITEM_CLASSES = const NAV_ITEM_CLASSES =
@ -53,7 +52,7 @@ export default function App(
<h1 class="text-2xl">LyricScreen</h1> <h1 class="text-2xl">LyricScreen</h1>
</a> </a>
<a tabIndex={11} href="/note" class={NAV_ITEM_CLASSES}>Notes</a> <a tabIndex={11} href="/note" class={NAV_ITEM_CLASSES}>Notes</a>
{contextState.user ? UserNavItems() : LoginNavItems()} {contextState?.user ? UserNavItems() : LoginNavItems()}
</nav> </nav>
</header> </header>
<main class="p-2"> <main class="p-2">

View file

@ -22,10 +22,12 @@ async function currentUser(
const { lsauth } = getCookies(request.headers); const { lsauth } = getCookies(request.headers);
log.debug("lsauth cookie:", lsauth); log.debug("lsauth cookie:", lsauth);
if (lsauth) { if (lsauth) {
const user = await getUserFromNonExpiredLoginToken(lsauth); try {
if (!user) hasBadAuthCookie = true; context.state.user = toPublicUser(
else { await getUserFromNonExpiredLoginToken(lsauth),
context.state.user = toPublicUser(user); );
} catch (e) {
hasBadAuthCookie = true;
} }
} }
const resp = await context.next(); const resp = await context.next();

View file

@ -1,27 +1,49 @@
// import { getToken, getUser } from "@/db/mod.ts"; import { getUserTeams } from "@/db/mod.ts";
// import * as base64 from "$std/encoding/base64.ts";
import { Handlers, PageProps } from "$fresh/server.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<unknown, ContextState> = { export const handler: Handlers<unknown, ContextState> = {
async GET(_request: Request, context) { 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<DashboardProps | undefined>,
) {
if (data) { if (data) {
return You(data); return Dashboard(data);
} else { } else {
return LoginRequired(); return LoginRequired();
} }
} }
function You(data: unknown) { function Dashboard({ teams, user }: DashboardProps) {
return ( return (
<p> <>
You are <pre>{JSON.stringify(data)}</pre>. <h2 class="text-4xl mb-2">
</p> Hello, {(user.displayName || user.username).trim()}!
</h2>
<h3 class="text-lg">
Which team are we working with today?
</h3>
<ul>
{teams.map((team) => (
<li key={team.id}>
<a href={`/team/${team.id}`}>{team.displayName}</a>
</li>
))}
</ul>
</>
); );
} }

View file

@ -1,6 +1,4 @@
import { Handlers } from "$fresh/server.ts"; 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 { deleteCookie, getCookies } from "$std/http/cookie.ts";
import { deleteToken } from "@/db/mod.ts"; import { deleteToken } from "@/db/mod.ts";

View file

@ -2,11 +2,13 @@ import { Handlers, PageProps } from "$fresh/server.ts";
import { createUser, PostgresError } from "@/db/mod.ts"; import { createUser, PostgresError } from "@/db/mod.ts";
import { hash } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { hash } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
type UserId = string;
interface RegistrationError { interface RegistrationError {
message: string; message: string;
} }
export const handler: Handlers<UserID | RegistrationError | null> = { export const handler: Handlers<UserId | RegistrationError | null> = {
async POST(request, context) { async POST(request, context) {
const formData = (await request.formData()); const formData = (await request.formData());
const username = formData.get("username"); const username = formData.get("username");
@ -20,14 +22,10 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
} }
const passwordDigest = await hash(password.toString()); const passwordDigest = await hash(password.toString());
try { try {
const result = await createUser({ const user = await createUser({
username: username.toString(), username: username.toString(),
passwordDigest, passwordDigest,
}); });
console.debug(result);
if (!result) throw "insert failed";
const [user, _team] = result;
if (!user) throw "insert failed";
return await context.render(user.id); return await context.render(user.id);
} catch (err) { } catch (err) {
if ( if (
@ -52,7 +50,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
}; };
export default function Register( export default function Register(
{ data: userId }: PageProps<UserID | RegistrationError | null>, { data: userId }: PageProps<UserId | RegistrationError | null>,
) { ) {
if (typeof userId == "string") { if (typeof userId == "string") {
return RegistrationSuccessful(userId); return RegistrationSuccessful(userId);
@ -61,7 +59,7 @@ export default function Register(
} }
} }
function RegistrationSuccessful(_userId: UserID) { function RegistrationSuccessful(_userId: UserId) {
return ( return (
<p> <p>
You're all signed up! Let's go <a href="/login">log in</a>! You're all signed up! Let's go <a href="/login">log in</a>!

46
routes/team/[id].tsx Normal file
View file

@ -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<TeamPageProps> = {
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 (
<>
<a href="/dashboard">Back to dashboard</a>
<h1>{displayName} - created {createdAt.toLocaleString()}</h1>
<h1 class="mt-4">Team Members</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
<a href={`/user/${user.id}`}>
{(user.displayName || user.username).trim()}
</a>
</li>
))}
</ul>
</>
);
}

30
routes/user/[id].tsx Normal file
View file

@ -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<UserPageProps> = {
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 (
<>
<h1>
{(displayName || username).trim()} - joined {createdAt.toLocaleString()}
</h1>
</>
);
}

View file

@ -31,6 +31,7 @@ export default {
"::-moz-focus-inner": { border: 0 }, "::-moz-focus-inner": { border: 0 },
"body input, body textarea": input, "body input, body textarea": input,
"body button, body input[type=submit]": button, "body button, body input[type=submit]": button,
"body ul": apply`list-disc ml-4`,
"body a": "body a":
apply`rounded ${focusRing} text-blue(600 700(hover:&) dark:(400 300(hover:&))`, apply`rounded ${focusRing} text-blue(600 700(hover:&) dark:(400 300(hover:&))`,
}), }),

View file

@ -48,7 +48,30 @@ export interface Token extends Created {
/** 32 bytes base64-encoded */ /** 32 bytes base64-encoded */
export type TokenDigest = string; export type TokenDigest = string;
export interface ContextState { export interface ContextState extends Record<string, unknown> {
user?: PublicUser; user?: PublicUser;
something?: string; something?: string;
} }
export type TeamUserStatus = "invited" | "accepted" | "owner";
export interface TeamUser {
userId: IdentifierFor<User>;
teamId: IdentifierFor<Team>;
status: TeamUserStatus;
}
export interface Playlist extends Identifiable, Timestamped {
displayName: string;
teamId: IdentifierFor<Team>;
}
export interface Display extends Identifiable, Timestamped {
displayName: string;
teamId: IdentifierFor<Team>;
playlistId: IdentifierFor<Playlist>;
isFrozen: boolean;
isBlanked: boolean;
currentSongIndex: number;
currentVerseIndex: number;
}
export type Ungenerated<T> = Omit<T, keyof Identifiable | keyof Timestamped>;