feat: add db models, setup prisma + utils

This commit is contained in:
Nicola Zambello 2023-02-14 10:10:10 +01:00
parent d799f3270a
commit a10816e43c
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
8 changed files with 294 additions and 148 deletions

View file

@ -1,13 +1,13 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client';
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant';
invariant(process.env.DATABASE_URL, 'DATABASE_URL must be set') invariant(process.env.DATABASE_URL, 'DATABASE_URL must be set');
const DATABASE_URL = process.env.DATABASE_URL const DATABASE_URL = process.env.DATABASE_URL;
let prisma: PrismaClient let prisma: PrismaClient;
declare global { declare global {
var __db__: PrismaClient var __db__: PrismaClient;
} }
// this is needed because in development we don't want to restart // this is needed because in development we don't want to restart
@ -15,20 +15,20 @@ declare global {
// create a new connection to the DB with every change either. // create a new connection to the DB with every change either.
// in production we'll have a single connection to the DB. // in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
prisma = getClient() prisma = getClient();
} else { } else {
if (!global.__db__) { if (!global.__db__) {
global.__db__ = getClient() global.__db__ = getClient();
} }
prisma = global.__db__ prisma = global.__db__;
} }
function getClient() { function getClient() {
invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set') invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set');
const databaseUrl = new URL(DATABASE_URL) const databaseUrl = new URL(DATABASE_URL);
console.log(`🔌 setting up prisma client to ${databaseUrl.host}`) console.log(`🔌 setting up prisma client to ${databaseUrl.host}`);
// NOTE: during development if you change anything in this function, remember // NOTE: during development if you change anything in this function, remember
// that this only runs once per server restart and won't automatically be // that this only runs once per server restart and won't automatically be
// re-run per request like everything else is. So if you need to change // re-run per request like everything else is. So if you need to change
@ -39,11 +39,11 @@ function getClient() {
url: databaseUrl.toString() url: databaseUrl.toString()
} }
} }
}) });
// connect eagerly // connect eagerly
client.$connect() client.$connect();
return client return client;
} }
export { prisma } export { prisma };

View file

@ -1,37 +1,55 @@
import type { User, Project } from '@prisma/client' import type { User, Project } from '@prisma/client';
import { prisma } from '~/db.server' import { prisma } from '~/db.server';
export type { Project } from '@prisma/client' export type { Project } from '@prisma/client';
export function getProject({ export function getProject({
id, id,
userId userId
}: Pick<Project, 'id'> & { }: Pick<Project, 'id'> & {
userId: User['id'] userId: User['id'];
}) { }) {
return prisma.project.findFirst({ return prisma.project.findFirst({
where: { id, userId } where: { id, userId }
}) });
} }
export function getProjects({ export async function getProjects({
userId, userId,
page, page,
offset, size,
orderBy orderBy,
order
}: { }: {
userId: User['id'] userId: User['id'];
page?: number page?: number;
offset?: number size?: number;
orderBy?: { [key in keyof Project]?: 'asc' | 'desc' } orderBy?: string;
order?: 'asc' | 'desc';
}) { }) {
return prisma.project.findMany({ const totalProjects = await prisma.project.count({
where: { userId }
});
const paginatedProjects = await prisma.project.findMany({
where: { userId }, where: { userId },
orderBy: orderBy || { updatedAt: 'desc' }, orderBy: {
skip: page && offset ? page * offset : 0, [orderBy || 'createdAt']: order || 'desc'
take: offset },
}) skip: page && size ? (page - 1) * size : 0,
take: size
});
const nextPage =
page && size && totalProjects > page * size ? page + 1 : null;
const previousPage = page && page > 2 ? page - 1 : null;
return {
total: totalProjects,
projects: paginatedProjects,
nextPage,
previousPage
};
} }
export function createProject({ export function createProject({
@ -40,7 +58,7 @@ export function createProject({
color, color,
userId userId
}: Pick<Project, 'name' | 'description' | 'color'> & { }: Pick<Project, 'name' | 'description' | 'color'> & {
userId: User['id'] userId: User['id'];
}) { }) {
return prisma.project.create({ return prisma.project.create({
data: { data: {
@ -49,7 +67,7 @@ export function createProject({
color, color,
userId userId
} }
}) });
} }
export function updateProject({ export function updateProject({
@ -58,7 +76,7 @@ export function updateProject({
description, description,
color color
}: Partial<Pick<Project, 'name' | 'description' | 'color'>> & { }: Partial<Pick<Project, 'name' | 'description' | 'color'>> & {
projectId: Project['id'] projectId: Project['id'];
}) { }) {
return prisma.project.update({ return prisma.project.update({
data: { data: {
@ -69,11 +87,14 @@ export function updateProject({
where: { where: {
id: projectId id: projectId
} }
}) });
} }
export function deleteProject({ id, userId }: Pick<Project, 'id'> & { userId: User['id'] }) { export function deleteProject({
id,
userId
}: Pick<Project, 'id'> & { userId: User['id'] }) {
return prisma.project.deleteMany({ return prisma.project.deleteMany({
where: { id, userId } where: { id, userId }
}) });
} }

View file

@ -1,39 +1,104 @@
import type { User, TimeEntry, Project } from '@prisma/client' import type { User, TimeEntry, Project } from '@prisma/client';
import { prisma } from '~/db.server' import { prisma } from '~/db.server';
export type { TimeEntry } from '@prisma/client' export type { TimeEntry } from '@prisma/client';
export function getTimeEntry({ export function getTimeEntry({
id, id,
userId userId
}: Pick<TimeEntry, 'id'> & { }: Pick<TimeEntry, 'id'> & {
userId: User['id'] userId: User['id'];
}) { }) {
return prisma.timeEntry.findFirst({ return prisma.timeEntry.findFirst({
where: { id, userId } where: { id, userId },
}) include: {
project: true
}
});
} }
export function getTimeEntries({ export async function getTimeEntries({
userId, userId,
projectId, projectId,
page, page,
offset, size,
orderBy orderBy,
order
}: { }: {
userId: User['id'] userId: User['id'];
projectId?: Project['id'] projectId?: Project['id'];
page?: number page?: number;
offset?: number size?: number;
orderBy?: { [key in keyof TimeEntry]?: 'asc' | 'desc' } orderBy?: string;
order?: 'asc' | 'desc';
}) { }) {
return prisma.timeEntry.findMany({ const totalTimeEntries = await prisma.timeEntry.count({
where: { userId, projectId }
});
const paginatedEntries = await prisma.timeEntry.findMany({
where: { userId, projectId }, where: { userId, projectId },
orderBy: orderBy || { updatedAt: 'desc' }, include: {
skip: page && offset ? page * offset : 0, project: true
take: offset },
}) orderBy: {
[orderBy || 'startTime']: order || 'desc'
},
skip: page && size ? (page - 1) * size : 0,
take: size
});
const monthAgo = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const weekAgo = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate() - 7
);
const monthEntries = await prisma.timeEntry.findMany({
where: {
userId,
projectId,
startTime: { gte: monthAgo },
endTime: { lte: new Date() }
}
});
const monthTotalHours =
monthEntries.reduce(
(acc, entry) =>
acc +
((entry.endTime || new Date(Date.now())).getTime() -
entry.startTime.getTime()),
0
) /
1000 /
60 /
60;
const weekTotalHours =
monthEntries
.filter((e) => e.startTime >= weekAgo)
.reduce(
(acc, entry) =>
acc +
((entry.endTime || new Date(Date.now())).getTime() -
entry.startTime.getTime()),
0
) /
1000 /
60 /
60;
const nextPage =
page && size && totalTimeEntries > page * size ? page + 1 : null;
const previousPage = page && page > 2 ? page - 1 : null;
return {
total: totalTimeEntries,
monthTotalHours,
weekTotalHours,
timeEntries: paginatedEntries,
nextPage,
previousPage
};
} }
export function createTimeEntry({ export function createTimeEntry({
@ -43,8 +108,8 @@ export function createTimeEntry({
userId, userId,
projectId projectId
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime'> & { }: Pick<TimeEntry, 'description' | 'startTime' | 'endTime'> & {
userId: User['id'] userId: User['id'];
projectId: Project['id'] projectId: Project['id'];
}) { }) {
return prisma.timeEntry.create({ return prisma.timeEntry.create({
data: { data: {
@ -54,7 +119,14 @@ export function createTimeEntry({
projectId, projectId,
userId userId
} }
}) });
}
export function stopAllTimeEntries(userId: User['id']) {
return prisma.timeEntry.updateMany({
where: { userId, endTime: null },
data: { endTime: new Date() }
});
} }
export function updateTimeEntry({ export function updateTimeEntry({
@ -63,8 +135,10 @@ export function updateTimeEntry({
startTime, startTime,
endTime, endTime,
projectId projectId
}: Partial<Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>> & { }: Partial<
timeEntryId: TimeEntry['id'] Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>
> & {
timeEntryId: TimeEntry['id'];
}) { }) {
return prisma.timeEntry.update({ return prisma.timeEntry.update({
data: { data: {
@ -76,11 +150,14 @@ export function updateTimeEntry({
where: { where: {
id: timeEntryId id: timeEntryId
} }
}) });
} }
export function deleteNote({ id, userId }: Pick<TimeEntry, 'id'> & { userId: User['id'] }) { export function deleteTimeEntry({
id,
userId
}: Pick<TimeEntry, 'id'> & { userId: User['id'] }) {
return prisma.timeEntry.deleteMany({ return prisma.timeEntry.deleteMany({
where: { id, userId } where: { id, userId }
}) });
} }

View file

@ -1,19 +1,19 @@
import type { Password, User } from "@prisma/client"; import type { Password, User } from '@prisma/client';
import bcrypt from "bcryptjs"; import bcrypt from 'bcryptjs';
import { prisma } from "~/db.server"; import { prisma } from '~/db.server';
export type { User } from "@prisma/client"; export type { User } from '@prisma/client';
export async function getUserById(id: User["id"]) { export async function getUserById(id: User['id']) {
return prisma.user.findUnique({ where: { id } }); return prisma.user.findUnique({ where: { id } });
} }
export async function getUserByEmail(email: User["email"]) { export async function getUserByEmail(email: User['email']) {
return prisma.user.findUnique({ where: { email } }); return prisma.user.findUnique({ where: { email } });
} }
export async function createUser(email: User["email"], password: string) { export async function createUser(email: User['email'], password: string) {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
return prisma.user.create({ return prisma.user.create({
@ -21,27 +21,28 @@ export async function createUser(email: User["email"], password: string) {
email, email,
password: { password: {
create: { create: {
hash: hashedPassword, hash: hashedPassword
}, }
}, }
}, }
}); });
} }
export async function deleteUserByEmail(email: User["email"]) { export async function deleteUserByEmail(email: User['email']) {
return prisma.user.delete({ where: { email } }); return prisma.user.delete({ where: { email } });
} }
export async function verifyLogin( export async function verifyLogin(
email: User["email"], email: User['email'],
password: Password["hash"] password: Password['hash']
) { ) {
const userWithPassword = await prisma.user.findUnique({ const userWithPassword = await prisma.user.findUnique({
where: { email }, where: { email },
include: { include: {
password: true, password: true
}, }
}); });
console.log(userWithPassword);
if (!userWithPassword || !userWithPassword.password) { if (!userWithPassword || !userWithPassword.password) {
return null; return null;
@ -51,6 +52,7 @@ export async function verifyLogin(
password, password,
userWithPassword.password.hash userWithPassword.password.hash
); );
console.log(isValid, password, userWithPassword.password.hash);
if (!isValid) { if (!isValid) {
return null; return null;

View file

@ -1,62 +1,67 @@
import { createCookieSessionStorage, redirect } from '@remix-run/node' import { createCookieSessionStorage, redirect } from '@remix-run/node';
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant';
import type { User } from '~/models/user.server' import type { User } from '~/models/user.server';
import { getUserById } from '~/models/user.server' import { getUserById } from '~/models/user.server';
invariant(process.env.SESSION_SECRET, 'SESSION_SECRET must be set') invariant(process.env.SESSION_SECRET, 'SESSION_SECRET must be set');
const SESSION_SECRET = process.env.SESSION_SECRET const SESSION_SECRET = process.env.SESSION_SECRET;
export const sessionStorage = createCookieSessionStorage({ export const sessionStorage = createCookieSessionStorage({
cookie: { cookie: {
name: '__session', name: 'wta__session',
httpOnly: true, httpOnly: true,
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
secrets: [SESSION_SECRET], secrets: [SESSION_SECRET],
secure: process.env.NODE_ENV === 'production' secure: process.env.NODE_ENV === 'production'
} }
}) });
const USER_SESSION_KEY = 'userId' const USER_SESSION_KEY = 'userId';
export async function getSession(request: Request) { export async function getSession(request: Request) {
const cookie = request.headers.get('Cookie') const cookie = request.headers.get('Cookie');
return sessionStorage.getSession(cookie) return sessionStorage.getSession(cookie);
} }
export async function getUserId(request: Request): Promise<User['id'] | undefined> { export async function getUserId(
const session = await getSession(request) request: Request
const userId = session.get(USER_SESSION_KEY) ): Promise<User['id'] | undefined> {
return userId const session = await getSession(request);
const userId = session.get(USER_SESSION_KEY);
return userId;
} }
export async function getUser(request: Request) { export async function getUser(request: Request) {
const userId = await getUserId(request) const userId = await getUserId(request);
if (userId === undefined) return null if (userId === undefined) return null;
const user = await getUserById(userId) const user = await getUserById(userId);
if (user) return user if (user) return user;
throw await logout(request) throw await logout(request);
} }
export async function requireUserId(request: Request, redirectTo: string = new URL(request.url).pathname) { export async function requireUserId(
const userId = await getUserId(request) request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const userId = await getUserId(request);
if (!userId) { if (!userId) {
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]) const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
throw redirect(`/login?${searchParams}`) throw redirect(`/login?${searchParams}`);
} }
return userId return userId;
} }
export async function requireUser(request: Request) { export async function requireUser(request: Request) {
const userId = await requireUserId(request) const userId = await requireUserId(request);
const user = await getUserById(userId) const user = await getUserById(userId);
if (user) return user if (user) return user;
throw await logout(request) throw await logout(request);
} }
export async function createUserSession({ export async function createUserSession({
@ -65,13 +70,13 @@ export async function createUserSession({
remember, remember,
redirectTo redirectTo
}: { }: {
request: Request request: Request;
userId: string userId: string;
remember: boolean remember: boolean;
redirectTo: string redirectTo: string;
}) { }) {
const session = await getSession(request) const session = await getSession(request);
session.set(USER_SESSION_KEY, userId) session.set(USER_SESSION_KEY, userId);
return redirect(redirectTo, { return redirect(redirectTo, {
headers: { headers: {
'Set-Cookie': await sessionStorage.commitSession(session, { 'Set-Cookie': await sessionStorage.commitSession(session, {
@ -80,14 +85,14 @@ export async function createUserSession({
: undefined : undefined
}) })
} }
}) });
} }
export async function logout(request: Request) { export async function logout(request: Request) {
const session = await getSession(request) const session = await getSession(request);
return redirect('/', { return redirect('/', {
headers: { headers: {
'Set-Cookie': await sessionStorage.destroySession(session) 'Set-Cookie': await sessionStorage.destroySession(session)
} }
}) });
} }

View file

@ -1,9 +1,9 @@
import { useMatches } from "@remix-run/react"; import { useMatches } from '@remix-run/react';
import { useMemo } from "react"; import { useMemo } from 'react';
import type { User } from "~/models/user.server"; import type { User } from '~/models/user.server';
const DEFAULT_REDIRECT = "/"; const DEFAULT_REDIRECT = '/';
/** /**
* This should be used any time the redirect path is user-provided * This should be used any time the redirect path is user-provided
@ -16,11 +16,11 @@ export function safeRedirect(
to: FormDataEntryValue | string | null | undefined, to: FormDataEntryValue | string | null | undefined,
defaultRedirect: string = DEFAULT_REDIRECT defaultRedirect: string = DEFAULT_REDIRECT
) { ) {
if (!to || typeof to !== "string") { if (!to || typeof to !== 'string') {
return defaultRedirect; return defaultRedirect;
} }
if (!to.startsWith("/") || to.startsWith("//")) { if (!to.startsWith('/') || to.startsWith('//')) {
return defaultRedirect; return defaultRedirect;
} }
@ -45,11 +45,11 @@ export function useMatchesData(
} }
function isUser(user: any): user is User { function isUser(user: any): user is User {
return user && typeof user === "object" && typeof user.email === "string"; return user && typeof user === 'object' && typeof user.email === 'string';
} }
export function useOptionalUser(): User | undefined { export function useOptionalUser(): User | undefined {
const data = useMatchesData("root"); const data = useMatchesData('root');
if (!data || !isUser(data.user)) { if (!data || !isUser(data.user)) {
return undefined; return undefined;
} }
@ -60,12 +60,12 @@ export function useUser(): User {
const maybeUser = useOptionalUser(); const maybeUser = useOptionalUser();
if (!maybeUser) { if (!maybeUser) {
throw new Error( throw new Error(
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." 'No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.'
); );
} }
return maybeUser; return maybeUser;
} }
export function validateEmail(email: unknown): email is string { export function validateEmail(email: unknown): email is string {
return typeof email === "string" && email.length > 3 && email.includes("@"); return typeof email === 'string' && email.length > 3 && email.includes('@');
} }

View file

@ -1,17 +1,17 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs';
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function seed() { async function seed() {
const email = 'admin@rawmaterial.it' const email = 'admin@rawmaterial.it';
// cleanup the existing database // cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => { await prisma.user.delete({ where: { email } }).catch(() => {
// no worries if it doesn't exist yet // no worries if it doesn't exist yet
}) });
const hashedPassword = await bcrypt.hash('admin', 10) const hashedPassword = await bcrypt.hash('rawmaterial', 10);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
@ -22,16 +22,36 @@ async function seed() {
} }
} }
} }
}) });
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { data: {
name: 'RawMaterial', name: 'RawMaterial',
description: 'Raw Material is a web app for managing your projects and tasks.', description:
color: '#333', 'Raw Material is a web app for managing your projects and tasks.',
color: 'green',
userId: user.id userId: user.id
} }
}) });
const otherProject = await prisma.project.create({
data: {
name: 'Memori',
description: 'Memori is a web app for managing your memories.',
color: 'violet',
userId: user.id
}
});
new Array(10).fill(0).forEach(async (_, index) => {
await prisma.project.create({
data: {
name: `Project ${index}`,
description: `Project ${index} description`,
color: 'red',
userId: user.id
}
});
});
await prisma.timeEntry.create({ await prisma.timeEntry.create({
data: { data: {
@ -41,25 +61,42 @@ async function seed() {
projectId: project.id, projectId: project.id,
userId: user.id userId: user.id
} }
}) });
await prisma.timeEntry.create({ await prisma.timeEntry.create({
data: { data: {
description: 'Database setup', description: 'Database setup same day',
startTime: new Date('2021-01-01T13:00:00.000Z'), startTime: new Date('2021-01-01T13:00:00.000Z'),
endTime: new Date('2021-01-01T19:00:00.000Z'), endTime: new Date('2021-01-01T19:00:00.000Z'),
projectId: otherProject.id,
userId: user.id
}
});
await prisma.timeEntry.create({
data: {
description: 'Database setup next day',
startTime: new Date('2021-01-02T13:00:00.000Z'),
endTime: new Date('2021-01-02T19:00:00.000Z'),
projectId: otherProject.id,
userId: user.id
}
});
await prisma.timeEntry.create({
data: {
description: 'Ongoing activity',
startTime: new Date('2021-01-02T13:00:00.000Z'),
projectId: project.id, projectId: project.id,
userId: user.id userId: user.id
} }
}) });
console.log(`Database has been seeded. 🌱`) console.log(`Database has been seeded. 🌱`);
} }
seed() seed()
.catch((e) => { .catch((e) => {
console.error(e) console.error(e);
process.exit(1) process.exit(1);
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) });

4
remix.env.d.ts vendored
View file

@ -1,2 +1,6 @@
/// <reference types="@remix-run/dev" /> /// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" /> /// <reference types="@remix-run/node" />
declare type Prettify<T> = {
[K in keyof T]: T[K]
} & {}