This commit is contained in:
Daniel Flanagan 2022-10-08 00:00:45 -05:00
parent cb69234651
commit f592524be1
Signed by: lytedev
GPG key ID: 5B2020A0F9921EF4
5 changed files with 120 additions and 85 deletions

View file

@ -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()"; const id = "id uuid primary key default generate_ulid()";
@ -37,7 +37,7 @@ const tables: Record<string, TableSpec> = {
"user": { "user": {
prepStatements: [ prepStatements: [
"drop type if exists user_status", "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: [ columns: [
id, id,
@ -151,36 +151,14 @@ try {
throw err; 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 { try {
const seedResult = await queryArray(seedQuery); await createNote({ content: "Hello, notes!" });
console.debug(seedResult); await createUser({
username: "lytedev",
passwordDigest:
"$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW",
});
} catch (err) { } catch (err) {
console.log("Failed to run migration seed query:", { ...err }); console.log("Failed to run seed database:", { ...err });
throw err; throw err;
} }

121
db/mod.ts
View file

@ -1,5 +1,6 @@
import { import {
Pool, Pool,
PoolClient,
PostgresError, PostgresError,
} from "https://deno.land/x/postgres@v0.16.1/mod.ts"; } from "https://deno.land/x/postgres@v0.16.1/mod.ts";
import { import {
@ -9,58 +10,62 @@ import {
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments"; } from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments";
import { config } from "@/config.ts"; 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 { PostgresError };
export { type QueryObjectResult }; 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>) {
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<T>( export async function queryObject<T>(
sql: string, sql: string,
args?: QueryArguments, args?: QueryArguments,
): Promise<QueryObjectResult<T> | null> { ): Promise<QueryObjectResult<T> | null> {
let result = null; return await dbOp(async (connection) =>
try { await connection.queryObject<T>({
const connection = await pool.connect(); camelcase: true,
try { text: sql.trim(),
result = await connection.queryObject<T>({ args,
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;
} }
export async function queryArray<T extends unknown>( export async function queryArray<T extends []>(
sql: string, sql: string,
args: QueryArguments[], args?: QueryArguments,
): Promise<QueryArrayResult<T[]> | null> { ): Promise<QueryArrayResult<T> | null> {
let result = null; return await dbOp(async (connection) =>
try { await connection.queryArray<T>({
const connection = await pool.connect(); text: sql.trim(),
try { args,
result = await connection.queryArray<T[]>({ })
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;
} }
export async function listNotes() { export async function listNotes() {
@ -78,9 +83,45 @@ export async function getNote(id: string | { id: string }) {
); );
} }
export async function createNote({ content }: Omit<Note, "id" | "createdAt">) { type Ungenerated<T> = Omit<T, keyof Identifiable | keyof Timestamped>;
export async function createNote({ content }: Ungenerated<Note>) {
return await queryObject<Note>( return await queryObject<Note>(
"insert into note (content) values ($1) returning *", "insert into note (content) values ($1) returning *",
[content], [content],
); );
} }
export async function createUser(
{ username, passwordDigest }: Ungenerated<User>,
) {
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<User>) {
if (!id && !username) throw "getUser called without id or username";
const column = id ? "id" : "username";
return await queryObject<User>(
`select * from "user" where "${column}" = $1`,
[id || username],
);
}

View file

@ -1,7 +1,7 @@
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
import { Page } from "@/components/Page.tsx"; import { Page } from "@/components/Page.tsx";
import { queryObject } from "@/db/mod.ts"; import { getUser } from "@/db/mod.ts";
type UserID = string; type UserID = string;
@ -25,17 +25,12 @@ export const handler: Handlers<UserID | LoginError | null> = {
return await context.render({ message: "no password provided" }); return await context.render({ message: "no password provided" });
} }
const result = await queryObject< const result = await getUser({ username: username.toString() });
{ id: string; username: string; password_digest: string }
>(
`select id, username, password_digest from "user" where username = $1`,
[username],
);
if (result == null || result.rows.length < 1) { if (result == null || result.rows.length < 1) {
return await invalidLogin(context); return await invalidLogin(context);
} }
const { rows: [{ id, password_digest }] } = result; const { rows: [{ id, passwordDigest }] } = result;
if (await compare(password.toString(), password_digest)) { if (await compare(password.toString(), passwordDigest)) {
return await context.render(id); return await context.render(id);
} else { } else {
return await invalidLogin(context); return await invalidLogin(context);

View file

@ -23,7 +23,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
} }
const password_digest = await hash(password.toString()); const password_digest = await hash(password.toString());
try { try {
const result = await queryObject<{ user_id: string }>( const result = await queryObject<{ userId: string }>(
` `
with new_user as ( with new_user as (
insert into "user" (username, password_digest, status) insert into "user" (username, password_digest, status)
@ -45,10 +45,9 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
); );
console.debug(result); console.debug(result);
if (!result) throw "insert failed"; if (!result) throw "insert failed";
const { rows: [{ user_id: id }] } = result; const { rows: [{ userId: id }] } = result;
return await context.render(id); return await context.render(id);
} catch (err) { } catch (err) {
console.log("Error fields:", { ...err });
if ( if (
err instanceof PostgresError && err.fields.code == "23505" && err instanceof PostgresError && err.fields.code == "23505" &&
err.fields.constraint == "user_username_key" err.fields.constraint == "user_username_key"
@ -62,7 +61,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
) { ) {
return await context.render({ return await context.render({
message: 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; throw err;

View file

@ -1,5 +1,27 @@
export interface Note { export interface Identifiable {
id: string; id: string;
}
export interface Creatable {
createdAt: Date; createdAt: Date;
}
export interface Updatable {
updatedAt: Date;
}
export type Timestamped = Creatable & Updatable;
export interface Note extends Identifiable, Timestamped {
content: string; content: string;
} }
export type UserStatus = "unverified" | "verified" | "superadmin";
export interface User extends Identifiable, Timestamped {
username: string;
email?: string;
passwordDigest: string;
displayName?: string;
status: UserStatus;
}