Woot!
This commit is contained in:
parent
50769ed32f
commit
70f2acc21e
134
db/migrations.ts
134
db/migrations.ts
|
@ -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
326
db/mod.ts
|
@ -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];
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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
46
routes/team/[id].tsx
Normal 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
30
routes/user/[id].tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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:&))`,
|
||||||
}),
|
}),
|
||||||
|
|
25
types.ts
25
types.ts
|
@ -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>;
|
||||||
|
|
Loading…
Reference in a new issue