chore!: migrate to docker environment

This commit is contained in:
Nicola Zambello 2023-06-22 13:11:03 +02:00
parent 543cfad176
commit 9497620acd
Signed by: nzambello
GPG key ID: 56E4A92C2C1E50BA
41 changed files with 28175 additions and 2191 deletions

1
.docker/.buildNodeID Normal file
View file

@ -0,0 +1 @@
4a7b79dacb6e1e604537fbbb4cd8ac7f18e0a0f4f5ed963d9914d7753dc170af

5
.docker/.token_seed Normal file
View file

@ -0,0 +1,5 @@
{
"registry-1.docker.io": {
"Seed": "0Vwpc9pKO8P+IoVikWF8Mw=="
}
}

0
.docker/.token_seed.lock Normal file
View file

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
docs

View file

@ -1,3 +1,3 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer" DATABASE_URL="file:./data.db?connection_limit=1"
SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS" SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS"
ALLOW_USER_SIGNUP=1 # remove this line to disable user signup ALLOW_USER_SIGNUP=1 # remove this line to disable user signup

3
.gitignore vendored
View file

@ -15,3 +15,6 @@ yarn-error.log
/build /build
/public/build /public/build
.env .env
*.db
*.sqlite

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

67
Dockerfile Normal file
View file

@ -0,0 +1,67 @@
# base node image
FROM node:16-bullseye-slim as base
# set for base and all layer that inherit from it
ENV NODE_ENV production
# Install openssl for Prisma
RUN apt-get update && apt-get install -y openssl sqlite3
# Install all node_modules, including dev dependencies
FROM base as deps
WORKDIR /timer
ADD package.json .npmrc ./
RUN npm install --include=dev
# Setup production node_modules
FROM base as production-deps
WORKDIR /timer
COPY --from=deps /timer/node_modules /timer/node_modules
ADD package.json .npmrc ./
RUN npm prune --omit=dev
# Build the app
FROM base as build
WORKDIR /timer
COPY --from=deps /timer/node_modules /timer/node_modules
ADD prisma .
RUN npx prisma generate
ADD . .
RUN npm run build
# Finally, build the production image with minimal footprint
FROM base
ENV DATABASE_URL=file:/data/sqlite.db
ENV PORT="8080"
ENV NODE_ENV="production"
ENV SESSION_SECRET="${SESSION_SECRET:-za1W297qRgKq6PNtm5EXJlOfIto6WTS}"
ENV ALLOW_USER_SIGNUP=0
# add shortcut for connecting to database CLI
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
WORKDIR /timer
COPY --from=production-deps /timer/node_modules /timer/node_modules
COPY --from=build /timer/node_modules/.prisma /timer/node_modules/.prisma
COPY --from=build /timer/build /timer/build
COPY --from=build /timer/public /timer/public
COPY --from=build /timer/package.json /timer/package.json
COPY --from=build /timer/start.sh /timer/start.sh
COPY --from=build /timer/prisma /timer/prisma
RUN chmod +x start.sh
ENTRYPOINT [ "./start.sh" ]
EXPOSE 8080

View file

@ -1,7 +1,24 @@
export const ALLOW_USER_SIGNUP = Boolean( import { getSetting } from './models/settings.server';
process.env.ALLOW_USER_SIGNUP || false import { countUsers } from './models/user.server';
);
export const ALLOW_USER_SIGNUP = process.env.ALLOW_USER_SIGNUP === '1' || false;
export const isSignupAllowed = async () => {
const allowUserSignup = await getSetting({ id: 'ALLOW_USER_SIGNUP' });
if (allowUserSignup?.value !== undefined && allowUserSignup?.value !== null) {
return (
allowUserSignup.value === 'true' ||
allowUserSignup.value === 'yes' ||
allowUserSignup.value === '1' ||
allowUserSignup.value === 'on'
);
}
let isFirstUser = (await countUsers()) === 0;
if (isFirstUser) {
return true;
}
export const isSignupAllowed = () => {
return !!ALLOW_USER_SIGNUP; return !!ALLOW_USER_SIGNUP;
}; };

View file

@ -0,0 +1,23 @@
import type { Settings } from '@prisma/client';
import { prisma } from '~/db.server';
export type { Settings } from '@prisma/client';
export function getSettings() {
return prisma.settings.findMany();
}
export function getSetting({ id }: { id: Settings['id'] }) {
return prisma.settings.findFirst({
where: { id }
});
}
export function updateSetting({ id, value }: Settings) {
return prisma.settings.upsert({
where: { id },
update: { value },
create: { id, value }
});
}

View file

@ -13,12 +13,17 @@ 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,
admin = false
) {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
return prisma.user.create({ return prisma.user.create({
data: { data: {
email, email,
admin,
password: { password: {
create: { create: {
hash: hashedPassword hash: hashedPassword
@ -99,6 +104,10 @@ export async function verifyLogin(
return userWithoutPassword; return userWithoutPassword;
} }
export async function countUsers() {
return prisma.user.count();
}
export async function getUsers({ export async function getUsers({
search, search,
page, page,

View file

@ -370,6 +370,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
}` }`
}} }}
> >
<Badge variant="light">ADMIN</Badge>
<NavLink <NavLink
component={Link} component={Link}
to="/users" to="/users"
@ -379,10 +380,21 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
<Users size={16} /> <Users size={16} />
</ThemeIcon> </ThemeIcon>
} }
rightSection={<Badge variant="light">ADMIN</Badge>}
variant="light" variant="light"
active={location.pathname.includes('/users')} active={location.pathname.includes('/users')}
/> />
<NavLink
component={Link}
to="/settings"
label="Settings"
icon={
<ThemeIcon variant="light">
<Settings size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/settings')}
/>
</Navbar.Section> </Navbar.Section>
)} )}
{user && ( {user && (
@ -466,7 +478,15 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
> >
<Clock /> <Clock />
</ThemeIcon> </ThemeIcon>
<span>WorkTimer</span> <span
style={{
fontWeight: 700,
lineHeight: 1.2,
fontSize: '1.25rem'
}}
>
WorkTimer
</span>
</Text> </Text>
<div <div

View file

@ -1,7 +1,22 @@
import type { LoaderArgs } from '@remix-run/node'; import type { LoaderArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node'; import { json, redirect } from '@remix-run/node';
import {
createStyles,
Title,
SimpleGrid,
Text,
Button,
ThemeIcon,
Grid,
Col,
Image,
Container,
Group,
Box
} from '@mantine/core';
import { Server, Lock, Users, FileText, GitHub } from 'react-feather';
import { getUserId } from '~/session.server'; import { getUserId } from '~/session.server';
import { Link } from '@remix-run/react';
export async function loader({ request }: LoaderArgs) { export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request); const userId = await getUserId(request);
@ -9,35 +24,254 @@ export async function loader({ request }: LoaderArgs) {
return json({}); return json({});
} }
export default function Index() { const features = [
return ( {
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}> icon: FileText,
<h1>Welcome to Remix</h1> title: 'Free and open source',
<ul> description:
<li> 'All packages are published under GNU Public license v3, you can self host this app and use it for free forever'
<a },
target="_blank" {
href="https://remix.run/tutorials/blog" icon: Server,
rel="noreferrer" title: 'Host anywhere',
description:
'You can host this app on your own server or using any cloud provider, your choice'
},
{
icon: Lock,
title: 'Privacy friendly, you own your data',
description:
'No analytics or tracking scripts, no ads, no data sharing. You are in control of your data'
},
{
icon: Users,
title: 'Flexible',
description:
'Use it for yourself as single user or invite your team to collaborate, you can also use it as a public service as admin'
}
];
const items = features.map((feature) => (
<div key={feature.title}>
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ deg: 133, from: 'blue', to: 'cyan' }}
> >
15m Quickstart Blog Tutorial <feature.icon />
</a> </ThemeIcon>
</li> <Text fz="lg" mt="sm" fw={500}>
<li> {feature.title}
<a </Text>
target="_blank" <Text c="dimmed" fz="sm">
href="https://remix.run/tutorials/jokes" {feature.description}
rel="noreferrer" </Text>
>
Deep Dive Jokes App Tutorial
</a>
</li>
<li>
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
Remix Docs
</a>
</li>
</ul>
</div> </div>
));
const rem = (value: number) => `${value / 16}rem`;
const useStyles = createStyles((theme) => ({
wrapper: {
position: 'relative',
boxSizing: 'border-box',
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white
},
inner: {
position: 'relative',
paddingTop: rem(32),
paddingBottom: rem(32),
[theme.fn.smallerThan('sm')]: {
paddingBottom: rem(16),
paddingTop: rem(16)
}
},
title: {
fontSize: rem(62),
fontWeight: 900,
lineHeight: 1.1,
margin: 0,
padding: 0,
color: theme.colorScheme === 'dark' ? theme.white : theme.black,
[theme.fn.smallerThan('sm')]: {
fontSize: rem(42),
lineHeight: 1.2
}
},
description: {
marginTop: theme.spacing.xl,
fontSize: rem(24),
[theme.fn.smallerThan('sm')]: {
fontSize: rem(18)
}
},
controls: {
marginTop: `calc(${theme.spacing.xl}px * 2)`,
[theme.fn.smallerThan('sm')]: {
marginTop: theme.spacing.xl
}
},
control: {
height: rem(54),
paddingLeft: rem(38),
paddingRight: rem(38),
[theme.fn.smallerThan('sm')]: {
height: rem(54),
paddingLeft: rem(18),
paddingRight: rem(18),
flex: 1
}
}
}));
export default function Index() {
const { classes } = useStyles();
return (
<>
<Container size="md" px="md" className={classes.inner}>
<h1 className={classes.title}>
A{' '}
<Text
component="span"
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
inherit
>
self-hosted
</Text>{' '}
privacy friendly time tracking app
</h1>
<Text className={classes.description} color="dimmed">
Time tracking app built with Remix, supports authentication, projects
management, and monthly or custom reports
</Text>
<Group className={classes.controls}>
<Button
component={Link}
to="/login"
size="xl"
className={classes.control}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan' }}
>
Get started
</Button>
<Button
component="a"
href="https://github.com/nzambello/work-timer"
target="_blank"
rel="noopener noreferrer"
size="xl"
variant="default"
className={classes.control}
leftIcon={<GitHub />}
>
GitHub
</Button>
</Group>
</Container>
<Container size="md" px="md" mt={120}>
<Title mt="xl" mb="md" order={2}>
Features
</Title>
<SimpleGrid
mb="xl"
cols={2}
spacing={30}
breakpoints={[{ maxWidth: 'md', cols: 1 }]}
>
{items}
</SimpleGrid>
<Grid gutter="lg" mt={120} align="flex-start">
<Col span={12} md={7}>
<Title order={3} mb="sm">
Light/dark theme
</Title>
<Group noWrap>
<Image
maw="50%"
mx="auto"
radius="md"
src="/images/00-time-entries-light.png"
alt="Time entries (light theme)"
/>
<Image
maw="50%"
mx="auto"
radius="md"
src="/images/01-time-entries-dark.png"
alt="Time entries (dark theme)"
/>
</Group>
</Col>
<Col span={12} md={5}>
<Title order={3} mb="sm">
Time entries management
</Title>
<Group noWrap>
<Image
maw="100%"
mx="auto"
radius="md"
src="/images/02-new-time-entry.png"
alt="Time entries editing"
/>
</Group>
</Col>
<Col span={12} md={5}>
<Title order={3} mb="sm">
Reports
</Title>
<Group noWrap>
<Image
maw="100%"
mx="auto"
radius="md"
src="/images/05-reports.png"
alt="Reports"
/>
</Group>
</Col>
<Col span={12} md={7}>
<Title order={3} mb="sm">
Projects
</Title>
<Group noWrap>
<Image
maw="50%"
mx="auto"
radius="md"
src="/images/03-projects.png"
alt="Projects management"
/>
<Image
maw="50%"
mx="auto"
radius="md"
src="/images/04-new-project.png"
alt="Projects management: new project"
/>
</Group>
</Col>
</Grid>
</Container>
</>
); );
} }

View file

@ -17,7 +17,7 @@ import {
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { AtSign, Lock } from 'react-feather'; import { AtSign, Lock } from 'react-feather';
import { verifyLogin } from '~/models/user.server'; import { countUsers, verifyLogin } from '~/models/user.server';
import { createUserSession, getUserId } from '~/session.server'; import { createUserSession, getUserId } from '~/session.server';
import { safeRedirect, validateEmail } from '~/utils'; import { safeRedirect, validateEmail } from '~/utils';
import { isSignupAllowed } from '~/config.server'; import { isSignupAllowed } from '~/config.server';
@ -26,8 +26,11 @@ export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request); const userId = await getUserId(request);
if (userId) return redirect('/time-entries'); if (userId) return redirect('/time-entries');
const isFirstUser = (await countUsers()) === 0;
if (isFirstUser) return redirect('/signup');
return json({ return json({
ALLOW_USER_SIGNUP: isSignupAllowed() ALLOW_USER_SIGNUP: await isSignupAllowed()
}); });
} }

176
app/routes/settings.tsx Normal file
View file

@ -0,0 +1,176 @@
import {
Button,
Paper,
Checkbox,
TextInput,
Alert,
Container
} from '@mantine/core';
import {
ActionArgs,
json,
LoaderArgs,
MetaFunction,
redirect
} from '@remix-run/node';
import { Form, useCatch, useLoaderData } from '@remix-run/react';
import { requireAdminUserId } from '~/session.server';
import { getSettings, updateSetting } from '~/models/settings.server';
import { Settings } from '~/models/settings.server';
import { isSignupAllowed } from '~/config.server';
import { AlertTriangle } from 'react-feather';
export const meta: MetaFunction = () => {
return {
title: 'Settings | WorkTimer',
description:
'Manage your WorkTimer instance. You must be logged in to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const userId = await requireAdminUserId(request);
if (!userId) return redirect('/login');
const settings = await getSettings();
if (!settings || !settings.find((s) => s.id === 'ALLOW_USER_SIGNUP')) {
return json({
settings: [
...((settings || []).filter((s) => s.id !== 'ALLOW_USER_SIGNUP') || []),
{
id: 'ALLOW_USER_SIGNUP',
value: (await isSignupAllowed()) ? 'true' : 'false'
}
]
});
}
return json({
settings
});
}
export async function action({ request }: ActionArgs) {
await requireAdminUserId(request);
const formData = await request.formData();
const id = (formData.get('id') || undefined) as string | undefined;
const value = (formData.get('value') || undefined) as
| string
| boolean
| undefined;
if (!id) {
throw new Response('Missing setting id', { status: 422 });
}
let parsedValue;
if (value === 'true' || value === 'on' || value === true) {
parsedValue = 'true';
} else if (value === 'false' || value === 'off' || value === false) {
parsedValue = 'false';
} else if (typeof value === 'string') {
parsedValue = value;
} else {
parsedValue = 'false';
}
await updateSetting({
id,
value: parsedValue
});
return redirect('/settings');
}
export default function Settings() {
const data = useLoaderData<typeof loader>();
return (
<div>
<h1
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
border: 0
}}
>
Settings
</h1>
<div role="region" id="settings">
<Container size="md">
{data.settings.map((setting) => (
<Form method="patch" key={setting.id}>
<input type="hidden" name="id" value={setting.id} />
<Paper
shadow="sm"
p="md"
radius="md"
mb="sm"
display="flex"
style={{
alignItems: 'center',
justifyContent: 'space-between'
}}
>
{['on', 'off', 'true', 'false'].includes(setting.value) ? (
<Checkbox
label={setting.id}
id={setting.id}
defaultChecked={
setting.value === 'true' || setting.value === 'on'
}
name="value"
/>
) : (
<TextInput
label={setting.id}
id={setting.id}
name="value"
defaultValue={setting.value}
/>
)}
<Button size="sm" type="submit">
Save
</Button>
</Paper>
</Form>
))}
</Container>
</div>
</div>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 404) {
return (
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
);
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}

View file

@ -1,6 +1,12 @@
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node'; import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node'; import { json, redirect } from '@remix-run/node';
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react'; import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams
} from '@remix-run/react';
import * as React from 'react'; import * as React from 'react';
import { import {
TextInput, TextInput,
@ -15,7 +21,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { AtSign, Check, Lock, X } from 'react-feather'; import { AtSign, Check, Lock, X } from 'react-feather';
import { createUserSession, getUserId } from '~/session.server'; import { createUserSession, getUserId } from '~/session.server';
import { createUser, getUserByEmail } from '~/models/user.server'; import { countUsers, createUser, getUserByEmail } from '~/models/user.server';
import { safeRedirect, validateEmail } from '~/utils'; import { safeRedirect, validateEmail } from '~/utils';
import { isSignupAllowed } from '~/config.server'; import { isSignupAllowed } from '~/config.server';
@ -23,15 +29,21 @@ export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request); const userId = await getUserId(request);
if (userId) return redirect('/time-entries'); if (userId) return redirect('/time-entries');
if (!isSignupAllowed()) { const isFirstUser = (await countUsers()) === 0;
if (!(await isSignupAllowed())) {
return redirect('/login'); return redirect('/login');
} }
return json({}); return json({
isFirstUser
});
} }
export async function action({ request }: ActionArgs) { export async function action({ request }: ActionArgs) {
if (!isSignupAllowed()) { const isFirstUser = (await countUsers()) === 0;
if (!isSignupAllowed() && !isFirstUser) {
return json( return json(
{ {
errors: { errors: {
@ -116,7 +128,7 @@ export async function action({ request }: ActionArgs) {
); );
} }
const user = await createUser(email, password); const user = await createUser(email, password, isFirstUser);
return createUserSession({ return createUserSession({
request, request,
@ -175,6 +187,7 @@ function getStrength(password: string) {
export default function SignUpPage() { export default function SignUpPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirectTo') ?? undefined; const redirectTo = searchParams.get('redirectTo') ?? undefined;
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const emailRef = React.useRef<HTMLInputElement>(null); const emailRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null); const passwordRef = React.useRef<HTMLInputElement>(null);
@ -293,6 +306,7 @@ export default function SignUpPage() {
<Button type="submit">Create account</Button> <Button type="submit">Create account</Button>
</Group> </Group>
{!loaderData.isFirstUser && (
<Box <Box
mt="md" mt="md"
sx={{ sx={{
@ -304,6 +318,7 @@ export default function SignUpPage() {
<strong>Log in</strong> <strong>Log in</strong>
</Link> </Link>
</Box> </Box>
)}
</Form> </Form>
</Box> </Box>
); );

View file

@ -55,6 +55,18 @@ export async function requireUserId(
return userId; return userId;
} }
export async function requireAdminUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const user = await getUser(request);
if (!user || !user.admin) {
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return user.id;
}
export async function requireUser(request: Request) { export async function requireUser(request: Request) {
const userId = await requireUserId(request); const userId = await requireUserId(request);

24712
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,13 +19,14 @@
"@mantine/notifications": "5.10.3", "@mantine/notifications": "5.10.3",
"@mantine/nprogress": "5.10.3", "@mantine/nprogress": "5.10.3",
"@mantine/remix": "5.10.3", "@mantine/remix": "5.10.3",
"@prisma/client": "4.10.1", "@prisma/client": "3.9.1",
"@remix-run/node": "^1.15.0", "@remix-run/node": "^1.15.0",
"@remix-run/react": "^1.15.0", "@remix-run/react": "^1.15.0",
"@remix-run/serve": "^1.15.0", "@remix-run/serve": "^1.15.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dayjs": "1.11.7", "dayjs": "1.11.7",
"esbuild": "0.16.3",
"isbot": "^3.6.5", "isbot": "^3.6.5",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"papaparse": "5.3.2", "papaparse": "5.3.2",
@ -39,8 +40,8 @@
"@commitlint/cli": "17.4.2", "@commitlint/cli": "17.4.2",
"@commitlint/config-conventional": "17.4.2", "@commitlint/config-conventional": "17.4.2",
"@release-it/conventional-changelog": "5.1.1", "@release-it/conventional-changelog": "5.1.1",
"@remix-run/dev": "^1.12.0", "@remix-run/dev": "^1.15.0",
"@remix-run/eslint-config": "^1.12.0", "@remix-run/eslint-config": "^1.15.0",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
"@types/nprogress": "0.2.0", "@types/nprogress": "0.2.0",
@ -56,7 +57,7 @@
"husky": "8.0.3", "husky": "8.0.3",
"is-ci": "3.0.1", "is-ci": "3.0.1",
"prettier": "2.8.4", "prettier": "2.8.4",
"prisma": "^4.9.0", "prisma": "3.9.1",
"release-it": "15.6.0", "release-it": "15.6.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "^4.8.4" "typescript": "^4.8.4"

BIN
prisma/dev.db-journal Normal file

Binary file not shown.

View file

@ -1,60 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Password" (
"hash" TEXT NOT NULL,
"userId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "TimeEntry" (
"id" TEXT NOT NULL,
"description" TEXT NOT NULL,
"startTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endTime" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "TimeEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
-- AddForeignKey
ALTER TABLE "Password" ADD CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30);

View file

@ -1,8 +0,0 @@
/*
Warnings:
- You are about to alter the column `duration` on the `TimeEntry` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `DoublePrecision`.
*/
-- AlterTable
ALTER TABLE "TimeEntry" ALTER COLUMN "duration" SET DATA TYPE DOUBLE PRECISION;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "dateFormat" TEXT NOT NULL DEFAULT 'en-US';

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "dateFormat" SET DEFAULT 'en-GB';

View file

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '',
ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION;

View file

@ -1,9 +0,0 @@
/*
Warnings:
- You are about to drop the column `defaultCurrency` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "defaultCurrency",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,51 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"admin" BOOLEAN NOT NULL DEFAULT false,
"dateFormat" TEXT NOT NULL DEFAULT 'en-GB',
"currency" TEXT NOT NULL DEFAULT '',
"defaultHourlyRate" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Password" (
"hash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TimeEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"description" TEXT NOT NULL,
"startTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endTime" DATETIME,
"duration" REAL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");

View file

@ -0,0 +1,5 @@
-- CreateTable
CREATE TABLE "Settings" (
"id" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);

View file

@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "sqlite"

View file

@ -1,5 +1,5 @@
datasource db { datasource db {
provider = "postgresql" provider = "sqlite"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@ -24,6 +24,11 @@ model User {
projects Project[] projects Project[]
} }
model Settings {
id String @id
value String
}
model Password { model Password {
hash String hash String

View file

@ -4,8 +4,8 @@ import bcrypt from 'bcryptjs';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function seed() { async function seed() {
const email = 'nicola@rawmaterial.it'; const email = 'nicola@nzambello.dev';
const adminEmail = 'admin@rawmaterial.it'; const adminEmail = 'admin@nzambello.dev';
// cleanup the existing database // cleanup the existing database
await prisma.user.delete({ where: { email } }).catch(() => { await prisma.user.delete({ where: { email } }).catch(() => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

19
start.sh Normal file
View file

@ -0,0 +1,19 @@
#!/bin/sh
set -ex
# This file is how Fly starts the server (configured in fly.toml). Before starting
# the server though, we need to run any prisma migrations that haven't yet been
# run, which is why this file exists in the first place.
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
# allocate swap space
# fallocate -l 512M /swapfile
# chmod 0600 /swapfile
# mkswap /swapfile
# echo 10 > /proc/sys/vm/swappiness
# swapon /swapfile
# echo 1 > /proc/sys/vm/overcommit_memory
npx prisma migrate deploy
yarn start

4773
yarn.lock

File diff suppressed because it is too large Load diff