diff --git a/db/migrations.ts b/db/migrations.ts index cc170cd..25914e2 100644 --- a/db/migrations.ts +++ b/db/migrations.ts @@ -1,4 +1,4 @@ -import { queryArray } from "@/db/mod.ts"; +import { createNote, createUser, queryArray } from "@/db/mod.ts"; const id = "id uuid primary key default generate_ulid()"; @@ -37,7 +37,7 @@ const tables: Record = { "user": { prepStatements: [ "drop type if exists user_status", - "create type user_status as enum ('unverified', 'verified', 'owner', 'superadmin')", + "create type user_status as enum ('unverified', 'verified', 'superadmin')", ], columns: [ id, @@ -151,36 +151,14 @@ try { throw err; } -const seedQuery = ` - -insert into note (content) values ('Hello, notes!'); - --- TODO: create reserved usernames? - -with new_user as ( - insert into "user" (username, password_digest, status) - values ('lytedev', '$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW', 'superadmin') - returning id as user_id -), new_team as ( - insert into "team" (display_name) - values ('superadmins') - 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; - -`; - -console.log(seedQuery); - try { - const seedResult = await queryArray(seedQuery); - console.debug(seedResult); + await createNote({ content: "Hello, notes!" }); + await createUser({ + username: "lytedev", + passwordDigest: + "$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW", + }); } catch (err) { - console.log("Failed to run migration seed query:", { ...err }); + console.log("Failed to run seed database:", { ...err }); throw err; } diff --git a/db/mod.ts b/db/mod.ts index 7c4bfeb..d6da14c 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -1,5 +1,6 @@ import { Pool, + PoolClient, PostgresError, } from "https://deno.land/x/postgres@v0.16.1/mod.ts"; import { @@ -9,58 +10,62 @@ import { } from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments"; import { config } from "@/config.ts"; -import { type Note } from "@/types.ts"; +import { + type Identifiable, + type Note, + type Timestamped, + type User, +} from "@/types.ts"; export { PostgresError }; export { type QueryObjectResult }; const pool = new Pool(config.postgres.url, 3, true); +async function dbOp(op: (connection: PoolClient) => Promise) { + let result = null; + let exception = null; + try { + const connection = await pool.connect(); + try { + result = await op(connection); + } catch (err) { + console.error("Error querying database:", { ...err }); + exception = err; + } finally { + connection.release(); + } + } catch (err) { + exception = err; + console.error("Error connecting to database:", err); + } + if (exception != null) throw exception; + return result; +} + export async function queryObject( sql: string, args?: QueryArguments, ): Promise | null> { - let result = null; - try { - const connection = await pool.connect(); - try { - result = await connection.queryObject({ - camelcase: true, - text: sql, - args, - }); - } catch (err) { - console.error("Error querying database:", { ...err }); - } finally { - connection.release(); - } - } catch (err) { - console.error("Error connecting to database:", err); - } - return result; + return await dbOp(async (connection) => + await connection.queryObject({ + camelcase: true, + text: sql.trim(), + args, + }) + ); } -export async function queryArray( +export async function queryArray( sql: string, - args: QueryArguments[], -): Promise | null> { - let result = null; - try { - const connection = await pool.connect(); - try { - result = await connection.queryArray({ - text: sql, - args, - }); - } catch (err) { - console.error("Error querying database:", { ...err }); - } finally { - connection.release(); - } - } catch (err) { - console.error("Error connecting to database:", err); - } - return result; + args?: QueryArguments, +): Promise | null> { + return await dbOp(async (connection) => + await connection.queryArray({ + text: sql.trim(), + args, + }) + ); } export async function listNotes() { @@ -78,9 +83,45 @@ export async function getNote(id: string | { id: string }) { ); } -export async function createNote({ content }: Omit) { +type Ungenerated = Omit; + +export async function createNote({ content }: Ungenerated) { return await queryObject( "insert into note (content) values ($1) returning *", [content], ); } + +export async function createUser( + { username, passwordDigest }: Ungenerated, +) { + return await queryObject<{ user_id: string }>( + ` + with new_user as ( + insert into "user" (username, password_digest, status) + values ($username, $passwordDigest, 'unverified') + 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 + `, + { username, passwordDigest, teamName: `${username}'s First Team` }, + ); +} + +export async function getUser({ id, username }: Partial) { + if (!id && !username) throw "getUser called without id or username"; + const column = id ? "id" : "username"; + return await queryObject( + `select * from "user" where "${column}" = $1`, + [id || username], + ); +} diff --git a/routes/login.tsx b/routes/login.tsx index 342d7f9..565c4ba 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,7 +1,7 @@ import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { Page } from "@/components/Page.tsx"; -import { queryObject } from "@/db/mod.ts"; +import { getUser } from "@/db/mod.ts"; type UserID = string; @@ -25,17 +25,12 @@ export const handler: Handlers = { return await context.render({ message: "no password provided" }); } - const result = await queryObject< - { id: string; username: string; password_digest: string } - >( - `select id, username, password_digest from "user" where username = $1`, - [username], - ); + const result = await getUser({ username: username.toString() }); if (result == null || result.rows.length < 1) { return await invalidLogin(context); } - const { rows: [{ id, password_digest }] } = result; - if (await compare(password.toString(), password_digest)) { + const { rows: [{ id, passwordDigest }] } = result; + if (await compare(password.toString(), passwordDigest)) { return await context.render(id); } else { return await invalidLogin(context); diff --git a/routes/register.tsx b/routes/register.tsx index d3059d3..1fe56d7 100644 --- a/routes/register.tsx +++ b/routes/register.tsx @@ -23,7 +23,7 @@ export const handler: Handlers = { } const password_digest = await hash(password.toString()); try { - const result = await queryObject<{ user_id: string }>( + const result = await queryObject<{ userId: string }>( ` with new_user as ( insert into "user" (username, password_digest, status) @@ -45,10 +45,9 @@ export const handler: Handlers = { ); console.debug(result); if (!result) throw "insert failed"; - const { rows: [{ user_id: id }] } = result; + const { rows: [{ userId: id }] } = result; return await context.render(id); } catch (err) { - console.log("Error fields:", { ...err }); if ( err instanceof PostgresError && err.fields.code == "23505" && err.fields.constraint == "user_username_key" @@ -62,7 +61,7 @@ export const handler: Handlers = { ) { return await context.render({ message: - `Username must ONLY be comprised of letters, number, dashes, and underscores and must be 2 to 79 characters long`, + `Username must ONLY be comprised of letters, number, dashes, and underscores`, }); } throw err; diff --git a/types.ts b/types.ts index 9d6bf44..54298e5 100644 --- a/types.ts +++ b/types.ts @@ -1,5 +1,27 @@ -export interface Note { +export interface Identifiable { id: string; +} + +export interface Creatable { createdAt: Date; +} + +export interface Updatable { + updatedAt: Date; +} + +export type Timestamped = Creatable & Updatable; + +export interface Note extends Identifiable, Timestamped { content: string; } + +export type UserStatus = "unverified" | "verified" | "superadmin"; + +export interface User extends Identifiable, Timestamped { + username: string; + email?: string; + passwordDigest: string; + displayName?: string; + status: UserStatus; +}