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 invariant from 'tiny-invariant'
import { PrismaClient } from '@prisma/client';
import invariant from 'tiny-invariant';
invariant(process.env.DATABASE_URL, 'DATABASE_URL must be set')
const DATABASE_URL = process.env.DATABASE_URL
invariant(process.env.DATABASE_URL, 'DATABASE_URL must be set');
const DATABASE_URL = process.env.DATABASE_URL;
let prisma: PrismaClient
let prisma: PrismaClient;
declare global {
var __db__: PrismaClient
var __db__: PrismaClient;
}
// 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.
// in production we'll have a single connection to the DB.
if (process.env.NODE_ENV === 'production') {
prisma = getClient()
prisma = getClient();
} else {
if (!global.__db__) {
global.__db__ = getClient()
global.__db__ = getClient();
}
prisma = global.__db__
prisma = global.__db__;
}
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
// 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
@ -39,11 +39,11 @@ function getClient() {
url: databaseUrl.toString()
}
}
})
});
// 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({
id,
userId
}: Pick<Project, 'id'> & {
userId: User['id']
userId: User['id'];
}) {
return prisma.project.findFirst({
where: { id, userId }
})
});
}
export function getProjects({
export async function getProjects({
userId,
page,
offset,
orderBy
size,
orderBy,
order
}: {
userId: User['id']
page?: number
offset?: number
orderBy?: { [key in keyof Project]?: 'asc' | 'desc' }
userId: User['id'];
page?: number;
size?: number;
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 },
orderBy: orderBy || { updatedAt: 'desc' },
skip: page && offset ? page * offset : 0,
take: offset
})
orderBy: {
[orderBy || 'createdAt']: order || 'desc'
},
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({
@ -40,7 +58,7 @@ export function createProject({
color,
userId
}: Pick<Project, 'name' | 'description' | 'color'> & {
userId: User['id']
userId: User['id'];
}) {
return prisma.project.create({
data: {
@ -49,7 +67,7 @@ export function createProject({
color,
userId
}
})
});
}
export function updateProject({
@ -58,7 +76,7 @@ export function updateProject({
description,
color
}: Partial<Pick<Project, 'name' | 'description' | 'color'>> & {
projectId: Project['id']
projectId: Project['id'];
}) {
return prisma.project.update({
data: {
@ -69,11 +87,14 @@ export function updateProject({
where: {
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({
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({
id,
userId
}: Pick<TimeEntry, 'id'> & {
userId: User['id']
userId: User['id'];
}) {
return prisma.timeEntry.findFirst({
where: { id, userId }
})
where: { id, userId },
include: {
project: true
}
});
}
export function getTimeEntries({
export async function getTimeEntries({
userId,
projectId,
page,
offset,
orderBy
size,
orderBy,
order
}: {
userId: User['id']
projectId?: Project['id']
page?: number
offset?: number
orderBy?: { [key in keyof TimeEntry]?: 'asc' | 'desc' }
userId: User['id'];
projectId?: Project['id'];
page?: number;
size?: number;
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 },
orderBy: orderBy || { updatedAt: 'desc' },
skip: page && offset ? page * offset : 0,
take: offset
})
include: {
project: true
},
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({
@ -43,8 +108,8 @@ export function createTimeEntry({
userId,
projectId
}: Pick<TimeEntry, 'description' | 'startTime' | 'endTime'> & {
userId: User['id']
projectId: Project['id']
userId: User['id'];
projectId: Project['id'];
}) {
return prisma.timeEntry.create({
data: {
@ -54,7 +119,14 @@ export function createTimeEntry({
projectId,
userId
}
})
});
}
export function stopAllTimeEntries(userId: User['id']) {
return prisma.timeEntry.updateMany({
where: { userId, endTime: null },
data: { endTime: new Date() }
});
}
export function updateTimeEntry({
@ -63,8 +135,10 @@ export function updateTimeEntry({
startTime,
endTime,
projectId
}: Partial<Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>> & {
timeEntryId: TimeEntry['id']
}: Partial<
Pick<TimeEntry, 'description' | 'startTime' | 'endTime' | 'projectId'>
> & {
timeEntryId: TimeEntry['id'];
}) {
return prisma.timeEntry.update({
data: {
@ -76,11 +150,14 @@ export function updateTimeEntry({
where: {
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({
where: { id, userId }
})
});
}

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { useMatches } from "@remix-run/react";
import { useMemo } from "react";
import { useMatches } from '@remix-run/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
@ -16,11 +16,11 @@ export function safeRedirect(
to: FormDataEntryValue | string | null | undefined,
defaultRedirect: string = DEFAULT_REDIRECT
) {
if (!to || typeof to !== "string") {
if (!to || typeof to !== 'string') {
return defaultRedirect;
}
if (!to.startsWith("/") || to.startsWith("//")) {
if (!to.startsWith('/') || to.startsWith('//')) {
return defaultRedirect;
}
@ -45,11 +45,11 @@ export function useMatchesData(
}
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 {
const data = useMatchesData("root");
const data = useMatchesData('root');
if (!data || !isUser(data.user)) {
return undefined;
}
@ -60,12 +60,12 @@ export function useUser(): User {
const maybeUser = useOptionalUser();
if (!maybeUser) {
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;
}
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 bcrypt from 'bcryptjs'
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient()
const prisma = new PrismaClient();
async function seed() {
const email = 'admin@rawmaterial.it'
const email = 'admin@rawmaterial.it';
// cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => {
// 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({
data: {
@ -22,16 +22,36 @@ async function seed() {
}
}
}
})
});
const project = await prisma.project.create({
data: {
name: 'RawMaterial',
description: 'Raw Material is a web app for managing your projects and tasks.',
color: '#333',
description:
'Raw Material is a web app for managing your projects and tasks.',
color: 'green',
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({
data: {
@ -41,25 +61,42 @@ async function seed() {
projectId: project.id,
userId: user.id
}
})
});
await prisma.timeEntry.create({
data: {
description: 'Database setup',
description: 'Database setup same day',
startTime: new Date('2021-01-01T13: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,
userId: user.id
}
})
});
console.log(`Database has been seeded. 🌱`)
console.log(`Database has been seeded. 🌱`);
}
seed()
.catch((e) => {
console.error(e)
process.exit(1)
console.error(e);
process.exit(1);
})
.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/node" />
declare type Prettify<T> = {
[K in keyof T]: T[K]
} & {}