diff --git a/app/db.server.ts b/app/db.server.ts index b098d76..e71c046 100644 --- a/app/db.server.ts +++ b/app/db.server.ts @@ -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 }; diff --git a/app/models/project.server.ts b/app/models/project.server.ts index 6949cc5..0eddf36 100644 --- a/app/models/project.server.ts +++ b/app/models/project.server.ts @@ -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 & { - 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 & { - 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> & { - 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 & { userId: User['id'] }) { +export function deleteProject({ + id, + userId +}: Pick & { userId: User['id'] }) { return prisma.project.deleteMany({ where: { id, userId } - }) + }); } diff --git a/app/models/timeEntry.server.ts b/app/models/timeEntry.server.ts index 94e644e..7752a87 100644 --- a/app/models/timeEntry.server.ts +++ b/app/models/timeEntry.server.ts @@ -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 & { - 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 & { - 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> & { - timeEntryId: TimeEntry['id'] +}: Partial< + Pick +> & { + timeEntryId: TimeEntry['id']; }) { return prisma.timeEntry.update({ data: { @@ -76,11 +150,14 @@ export function updateTimeEntry({ where: { id: timeEntryId } - }) + }); } -export function deleteNote({ id, userId }: Pick & { userId: User['id'] }) { +export function deleteTimeEntry({ + id, + userId +}: Pick & { userId: User['id'] }) { return prisma.timeEntry.deleteMany({ where: { id, userId } - }) + }); } diff --git a/app/models/user.server.ts b/app/models/user.server.ts index cce401e..1a0c8c8 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -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; diff --git a/app/session.server.ts b/app/session.server.ts index 65f5c2e..19b14ad 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -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 { - const session = await getSession(request) - const userId = session.get(USER_SESSION_KEY) - return userId +export async function getUserId( + request: Request +): Promise { + 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) } - }) + }); } diff --git a/app/utils.ts b/app/utils.ts index a7eb66e..c766eee 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -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('@'); } diff --git a/prisma/seed.ts b/prisma/seed.ts index 240a4f6..b271df0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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(); + }); diff --git a/remix.env.d.ts b/remix.env.d.ts index dcf8c45..9439bce 100644 --- a/remix.env.d.ts +++ b/remix.env.d.ts @@ -1,2 +1,6 @@ /// /// + +declare type Prettify = { + [K in keyof T]: T[K] +} & {}