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()";
@ -37,7 +37,7 @@ const tables: Record<string, TableSpec> = {
"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;
}

115
db/mod.ts
View file

@ -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<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>(
sql: string,
args?: QueryArguments,
): Promise<QueryObjectResult<T> | null> {
let result = null;
try {
const connection = await pool.connect();
try {
result = await connection.queryObject<T>({
return await dbOp(async (connection) =>
await connection.queryObject<T>({
camelcase: true,
text: sql,
text: sql.trim(),
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,
args: QueryArguments[],
): Promise<QueryArrayResult<T[]> | null> {
let result = null;
try {
const connection = await pool.connect();
try {
result = await connection.queryArray<T[]>({
text: sql,
args?: QueryArguments,
): Promise<QueryArrayResult<T> | null> {
return await dbOp(async (connection) =>
await connection.queryArray<T>({
text: sql.trim(),
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() {
@ -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>(
"insert into note (content) values ($1) returning *",
[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 { 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<UserID | LoginError | null> = {
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);

View file

@ -23,7 +23,7 @@ export const handler: Handlers<UserID | RegistrationError | null> = {
}
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<UserID | RegistrationError | null> = {
);
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<UserID | RegistrationError | null> = {
) {
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;

View file

@ -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;
}