Compare commits

...

40 commits
main ... main

Author SHA1 Message Date
54907d6eec components/navigation/navigation: actually watch for route changes
the useRoute() did not return ref,
so the previous implementation did not work
2023-12-21 03:39:47 +01:00
bcace5cbd7 components/navigation/navigation: watch for route changes
and select value in nav properly
2023-12-21 03:31:33 +01:00
cf29c26abc components/navigation/navigation: move "login" to bottom
and rename it to "My account"
2023-12-21 03:29:42 +01:00
ac49090615 components/navigation/navigation: add light/dark theme switcher 2023-12-21 03:18:41 +01:00
187e432182 pages/login: await when "updating" user info for the first time 2023-12-20 06:03:56 +01:00
f11b4c9a75 page/login: update cookie from useCookie on login/logout
I think this fixes an issue where auth token cookie
gets deleted when page is not being refreshed
after login.
2023-12-20 06:03:12 +01:00
eabb7b89c7 replace all $fetch with useRequestFetch 2023-12-20 05:45:29 +01:00
015b66706f Update dependencies 2023-12-20 05:26:19 +01:00
f308ab80c9 Use one format for slashes prepend/append where fetch is used
always prepend slash
never append slash
2023-12-20 05:06:38 +01:00
8bfc059b66 server/utils/database: ensure that database engines exists. 2023-12-20 04:01:04 +01:00
a74e148da4 pages/orders and pages/clients: add missing logic in handleSubmit
in clients, adding 1 to total was missing
in orders, adding the added memeber to the list and adding to total was missing
2023-12-19 02:34:45 +01:00
ed739e27fd /page/orders: create
this is pretty initial and the are still no per order page.
2023-12-18 23:17:06 +01:00
8bd41f8df3 create api/orders/count.get endpoint
this api returns count of all orders
2023-12-18 23:15:01 +01:00
b12e91ed13 Refactor entry editor and add some new types to it 2023-12-18 23:14:15 +01:00
0151a6c713 Update dependencies 2023-12-16 15:07:51 +01:00
5d1fc30601 add the newly created client to clients list 2023-12-12 19:18:13 +01:00
3101858eed check for undefined values when normalizing form new client data 2023-12-12 19:17:32 +01:00
f7519f32b3 nav: select automatically current page when visiting first time
for example after refresh, or when component gets loaded
2023-12-12 18:59:42 +01:00
10ff342991 api/firstRun.post: properly initialize database using @prisma/migrate
instead of executing the command,
which was not available in the build
now we use the proper library to initialize the database.
2023-11-09 23:52:56 +01:00
1d8220d92c page/firstRun: use password input for password
because why password should be shown in plain text??
2023-11-09 18:48:37 +01:00
ebf5690519 [BREAKING] Auth: replace current auth tokens with more secure ones
previously tokens were only like IDs, time based and incrementing counter.
An attacker could easily bruteforce them.
This patch changes tokens to be completely random.

fixes #2
2023-11-09 18:28:09 +01:00
434ae5843e api/firstRun.post: await for user creation 2023-11-09 17:29:41 +01:00
e509bb22c1 components/entryEditor: Remove unused "modelValue" property
makes eslint happy about that file :)
2023-11-09 13:16:21 +01:00
500a9ad595 page/client: Don't default form to undefined.
This fixes an issue when user does not click the form field.
it is not being defined, meaning the field will not be updated.
2023-11-09 11:39:00 +01:00
f5b205f90c api/clients.post (checkIsClient): Fix opposite condition for the patch parameter.
previously, when patch was true, it was requiring the values in the fields.
and vice versa.
It turns out the "patch" variable was negated for some reason.
2023-11-09 11:39:00 +01:00
ccbbfd1d3b README: Precise a description of running of a project. 2023-11-09 04:43:54 +01:00
5602c22861 Don't import Decimal from internal libraries
This was causing the project build to not work properly.
2023-11-09 04:34:25 +01:00
95137acbed Workflow: rename the artifact with a better name 2023-11-09 02:43:24 +01:00
d5d2f2e889 Add forgejo actions 2023-11-09 02:28:33 +01:00
f26206de87 Reconfigure Vuetify to be installed as Vuetify doc states
As of today, the vutetify doc states how to install it in the nuxt.
So following the guide I did it.
Now the website layout does not "jump" with offsets on load.
2023-11-09 00:45:37 +01:00
aa645e710e Add nix shell 2023-11-08 06:03:30 +01:00
eebf25198d Replace mysql2 with prisma
also I updated packages,
and properly typed api input
a lot of time was spent, I don't remeber what really I did x3
but everything was related to replacing mysql2 with prisma
2023-11-08 05:36:12 +01:00
be1e3909b6 Delete unrelated testing stuff 2023-11-07 17:36:30 +01:00
1d893c4a78 Update dependencies, fix (auto)import problems 2023-11-07 17:36:30 +01:00
267a83d484 fix no template for index.vue 2023-06-14 13:52:53 +02:00
4720202d8a remove playground junk 2023-06-14 13:47:21 +02:00
90932a49c8 Add database initialization
now, when the project is ran without configured database,
it will prompt for the first user to configure
the database and add the first user
2023-06-14 13:00:19 +02:00
cbfc4e9317 fix auth check if database is uninitialised 2023-06-14 12:37:57 +02:00
75f809051c refactor out password hashing in login.post.ts
this will be used to create the first user account in an empty database
2023-06-14 11:48:31 +02:00
bbe0c91d7e allow to override default database config in new
this way we can temporarily allow to execute multiple statements
in one query.
2023-06-14 10:41:25 +02:00
63 changed files with 7897 additions and 4647 deletions

View file

@ -0,0 +1,32 @@
name: Build dev
on:
push:
jobs:
build:
runs-on: docker
steps:
# Setup
- uses: https://code.forgejo.org/actions/setup-node@v3
with:
node-version: 18
- uses: https://code.forgejo.org/actions/checkout@v3
- name: Install dependencies
run: npm install -D
# Compile
- name: Build project
if: success()
run: npx nuxi build
# Upload
- name: Package to tar
if: success()
run: tar --create --file build.tar .output/*
- name: Upload build
if: success()
uses: https://code.forgejo.org/actions/upload-artifact@v3
with:
name: WorkshopTasker-${{ github.ref_name }}-${{ github.run_number }}.tar
path: build.tar

View file

@ -6,18 +6,26 @@ Allows for managing order tasks and imported products for a small workshop.
# Running # Running
The project was tested with the nodejs version v18.16.0. It may not work with newer versions of nodejs. The project was tested with the nodejs version v18.17.1. It may not work with versions higher than 18 of nodejs.
Dev dependencies are required. `npm install -D`
Project uses Oracle MySQL as a database store. It uses environment variables for the connection settings. All provided variables below are required. Project uses Oracle MySQL as a database store. It uses environment variables for the connection settings. All provided variables below are required.
| Environment variable | Description | | Environment variable | Description |
|----------------------|-------------------| |----------------------|-------------------|
| `DB_HOST` | Database host | | `DB_URL` | Database url, see [this](https://www.prisma.io/docs/concepts/database-connectors/mysql#connection-url) |
| `DB_PORT` | Database port |
| `DB_USER` | Database user |
| `DB_PASSWORD` | Database password |
| `DB_SCHEMA` | Database schema |
After setting variables, you can run the project using `npx nuxi dev` or `npx nuxi preview`. ## From Repository
Dev dependencies are required. `npm install -D`.
After setting variables, you can run the project using `npx nuxi dev`.
You can also setup variables by placing .env file in the root of the repository.
When using nix, you can use `nix-shell` to get prisma engines installed in your environment.
## From Actions
You can download build from [repository actions](https://git.proot.pl/Wroclaw/WorkshopTasker/actions?workflow=build.yml).
You need to extract tar file.
After setting environment variables, you can launch the project using the `node server/index.mjs` command.

View file

@ -1,20 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
type optionalMap<Optional> = Optional extends true ? undefined : string | number; import FormClient from "~/components/formClient.vue";
// type typeMap<Type extends string = {};
export type fieldDefinition<Optional extends boolean = boolean> = { type optionalMap<Optional, type> = Optional extends true ? undefined | type : type;
type typeMap = {
"text": string,
"password": string,
"number": number,
"boolean": boolean,
"none": undefined,
"client": `${bigint}`
};
export type fieldDefinition<Optional extends boolean = boolean, type extends keyof typeMap = keyof typeMap> = {
key: string, key: string,
label?: string, label?: string,
type: "text" | "number", type: type,
optional?: Optional, optional?: Optional,
value?: optionalMap<Optional>, value?: optionalMap<Optional, typeMap[type]>,
} }
const props = defineProps<{ const props = defineProps<{
fields: Array<fieldDefinition>, fields: Array<fieldDefinition>,
modelValue?: any,
}>(); }>();
// eslint-disable-next-line func-call-spacing // eslint-disable-next-line func-call-spacing
@ -23,7 +32,7 @@ const emit = defineEmits<{
(e: "updateSubModelValue", key: fieldDefinition["key"], value: fieldDefinition["value"]): void, (e: "updateSubModelValue", key: fieldDefinition["key"], value: fieldDefinition["value"]): void,
}>(); }>();
const modelValue = ref<{[key: string]: string | number | undefined}>({}); const modelValue = ref<{[key: string]: string | number | boolean | undefined}>({});
for (const i of props.fields) { for (const i of props.fields) {
modelValue.value[i.key] = i.value; modelValue.value[i.key] = i.value;
@ -40,12 +49,43 @@ emit("update:modelValue", modelValue.value);
</script> </script>
<template> <template>
<VTextField <div v-for="i of fields" :key="i.key">
v-for="i of fields" <VTextField
:key="i.key" v-if="i.type == 'text'"
:model-value="modelValue[i.key]" :model-value="modelValue[i.key]"
:label="i.label" :label="i.label"
:type="i.type" type="text"
@update:model-value="v => updateModel(i.key, v)" @update:model-value="v => updateModel(i.key, v)"
/> />
<VTextField
v-if="i.type == 'password'"
:model-value="modelValue[i.key]"
:label="i.label"
type="password"
@update:model-value="v => updateModel(i.key, v)"
/>
<VTextField
v-if="i.type == 'number'"
:model-value="modelValue[i.key]"
:label="i.label"
type="number"
@update:model-value="v => updateModel(i.key, Number(v))"
/>
<v-checkbox
v-if="i.type == 'boolean'"
:label="i.label"
:model-value="modelValue[i.key]"
@update:model-value="v => updateModel(i.key, Boolean(v))"
/>
<p v-if="i.type == 'none'">
{{ i.label }}
</p>
<FormClient
v-if="i.type == 'client'"
:model-value="modelValue[i.key] as `${bigint}`"
:label="i.label"
:optional="i.optional"
@update:model-value="v => updateModel(i.key, v)"
/>
</div>
</template> </template>

37
components/formClient.vue Normal file
View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { useFetch, createError } from '#app';
const props = defineProps<{
label?: string,
optional?: boolean,
modelValue?: `${bigint}`,
}>();
// eslint-disable-next-line func-call-spacing
const emit = defineEmits<{
(e: "update:modelValue", value: `${bigint}`): void,
}>();
// FIXME: allow to search all clients instead of newest 50 (needs api call)
const clientsRequest = await useFetch("/api/clients");
if (clientsRequest.error.value) throw createError(clientsRequest.error.value?.data ?? "");
const clients = clientsRequest.data.value?.map((e) => {
return {
value: e.id,
title: e.name ?? `[null] (${e.id})`,
props: {
subtitle: e.address,
},
};
}) ?? [];
</script>
<template>
<v-autocomplete
:label="label ?? 'Client'"
:model-value="modelValue"
:items="clients"
:clearable="optional"
@update:model-value="v => emit('update:modelValue', v)"
/>
</template>

View file

@ -1,10 +1,22 @@
<script setup> <script setup>
import { useDisplay } from "vuetify/lib/framework.mjs"; import { useDisplay, useTheme } from "vuetify/lib/framework.mjs";
import { ref } from "vue"; import { ref, watch } from "vue";
import { navigateTo } from "#app"; import { navigateTo, useRouter } from "#app";
const route = useRouter().currentRoute;
const { mobile } = useDisplay(); const { mobile } = useDisplay();
const navOpen = ref(!mobile.value); const navOpen = ref(!mobile.value);
const navSelected = ref([route.value.path]);
watch(route, (v) => {
navSelected.value = [v.path];
});
const theme = useTheme();
function switchTheme() {
theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark';
}
</script> </script>
<template> <template>
@ -13,22 +25,17 @@ const navOpen = ref(!mobile.value);
<VToolbarTitle>Database Project</VToolbarTitle> <VToolbarTitle>Database Project</VToolbarTitle>
</VAppBar> </VAppBar>
<VNavigationDrawer v-model="navOpen" :temporary="mobile"> <VNavigationDrawer v-model="navOpen" :temporary="mobile">
<!-- <VList> <VList v-model:selected="navSelected" density="compact" nav>
<VListItem
prepend-avatar="https://cdn.discordapp.com/avatars/317001025074757632/248f503c44bc95ac75f55f5835e6ff9f.png?size=512"
title="Anonymous"
/>
</VList> -->
<VDivider />
<VList density="compact" nav>
<VListItem prepend-icon="mdi-login" title="Login" value="/login" @click="navigateTo('/login')" />
<VListItem prepend-icon="mdi-account" title="Clients" value="/clients" @click="navigateTo('/clients')" /> <VListItem prepend-icon="mdi-account" title="Clients" value="/clients" @click="navigateTo('/clients')" />
<VListItem prepend-icon="mdi-receipt-text" title="Orders" value="/orders" @click="navigateTo('/orders')" />
<VDivider /> <VDivider />
<VListItem prepend-icon="mdi-home" title="Home" value="/" @click="navigateTo('/')" />
<VListItem prepend-icon="mdi-table" title="Table Example" value="/tableExample" @click="navigateTo('/tableExample')" />
<VListItem prepend-icon="mdi-form-textarea" title="Echo Form" value="/forms" @click="navigateTo('/forms')" />
</VList> </VList>
<template #append>
<VList v-model:selected="navSelected" density="compact" nav>
<VDivider />
<VListItem prepend-icon="mdi-theme-light-dark" title="Switch theme" class="mx-auto" @click="switchTheme()" />
<VListItem prepend-icon="mdi-login" title="My account" value="/login" @click="navigateTo('/login')" />
</VList>
</template>
</VNavigationDrawer> </VNavigationDrawer>
</template> </template>

View file

@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { order as orderType } from '~/utils/types/database'; import type { useFetch } from '#imports';
type Order = Awaited<ReturnType<typeof useFetch<void, any, "/api/orders/:id", "get">>>["data"]["value"];
const props = defineProps<{ const props = defineProps<{
order?: orderType order?: Order | undefined
}>(); }>();
</script> </script>
@ -24,7 +26,7 @@ const props = defineProps<{
</thead> </thead>
<tbody> <tbody>
<tr v-for="i in props.order.work" :key="i.id"> <tr v-for="i in props.order.work" :key="i.id">
<td>{{ i.is_fulfilled }}</td> <td>{{ i.fulfilled }}</td>
<td> <td>
{{ i.offer.name }} {{ i.offer.name }}
</td> </td>

View file

@ -1,11 +0,0 @@
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<VBtn @click="count++">
Clicky {{ count }} times
</VBtn>
</template>

8
middleware/firstRun.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineNuxtRouteMiddleware, navigateTo, useFetch } from "nuxt/app";
export default defineNuxtRouteMiddleware(async (to, from) => {
const firstRun = await useFetch("/api/firstRun");
if (firstRun.data.value)
return navigateTo({ path: "/firstRun" });
});

View file

@ -1,10 +1,19 @@
import { defineNuxtConfig } from "nuxt/config"; import { defineNuxtConfig } from "nuxt/config";
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
pages: true, pages: true,
build: { build: {
transpile: ["vuetify"], transpile: ["vuetify"],
}, },
modules: [
(_options, nuxt) => {
nuxt.hooks.hook('vite:extendConfig', (config) => {
config.plugins?.push(vuetify({ autoImport: true }));
});
},
],
css: [ css: [
"vuetify/lib/styles/main.sass", "vuetify/lib/styles/main.sass",
"@mdi/font/css/materialdesignicons.min.css", "@mdi/font/css/materialdesignicons.min.css",
@ -18,7 +27,11 @@ export default defineNuxtConfig({
}, },
}, },
vite: { vite: {
// devBundler: "legacy" vue: {
template: {
transformAssetUrls,
},
},
}, },
imports: { imports: {
autoImport: false, autoImport: false,

9805
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,15 +13,18 @@
"devDependencies": { "devDependencies": {
"@mdi/font": "^7.2.96", "@mdi/font": "^7.2.96",
"@nuxtjs/eslint-config-typescript": "^12.0.0", "@nuxtjs/eslint-config-typescript": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^5.59.0", "@typescript-eslint/parser": "^6.9.1",
"eslint": "^8.39.0", "eslint": "^8.39.0",
"nuxt": "3.4.0", "nuxt": "3.8.2",
"prisma": "5.7.1",
"sass": "^1.62.0", "sass": "^1.62.0",
"vite-plugin-vuetify": "^1.0.2", "vite-plugin-vuetify": "^2.0.1",
"vuetify": "^3.1.15" "vuetify": "^3.1.15"
}, },
"dependencies": { "dependencies": {
"mysql2": "^3.2.3" "@prisma/client": "5.7.1",
"@prisma/engines": "5.7.1",
"@prisma/migrate": "5.7.1"
} }
} }

View file

@ -1,29 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
/* global $fetch */ import { ref, type Ref } from "vue";
import { useRoute, useFetch, createError } from "nuxt/app";
import { ref, Ref } from "vue";
import { VBtn, VForm } from "vuetify/components"; import { VBtn, VForm } from "vuetify/components";
import PagedList from "~/components/pagedList.vue"; import PagedList from "~/components/pagedList.vue";
import Snowflake from "~/utils/snowflake"; import Snowflake from "~/utils/snowflake";
import { client as clientType, order, orderSummary } from "~/utils/types/database";
import OrderView from "~/components/orderView.vue"; import OrderView from "~/components/orderView.vue";
import EntryEditor, { fieldDefinition } from "~/components/entryEditor.vue"; import EntryEditor, { type fieldDefinition } from "~/components/entryEditor.vue";
import { useRoute, useFetch, createError, useRequestFetch } from "#imports";
const route = useRoute(); const route = useRoute();
const fetch = useRequestFetch();
const id = route.params.id; const id = route.params.id;
const clientRequest = await useFetch(`/api/clients/${id}`); const clientRequest = await useFetch(`/api/clients/${id}` as "/api/clients/:id");
if (clientRequest.error.value) throw createError(clientRequest.error.value?.data ?? ""); if (clientRequest.error.value) throw createError(clientRequest.error.value?.data ?? "");
const client = clientRequest.data as Ref<clientType>; type Client = NonNullable<typeof clientRequest.data.value>;
const client = clientRequest.data as Ref<Client>;
const clientOrdersRequest = await useFetch(`/api/clients/${id}/orders`); const clientOrdersRequest = await useFetch(`/api/clients/${id}/orders` as "/api/clients/:id/orders");
if (clientOrdersRequest.error.value) throw createError(clientOrdersRequest.error.value?.data ?? ""); if (clientOrdersRequest.error.value) throw createError(clientOrdersRequest.error.value?.data ?? "");
const clientOrders = clientOrdersRequest.data as Ref<Array<orderSummary>>; type OrderSummary = NonNullable<typeof clientOrdersRequest.data.value>;
const clientOrders = clientOrdersRequest.data as Ref<OrderSummary>;
type Order = Awaited<ReturnType<typeof useFetch<void, any, "/api/orders/:id", "get">>>["data"]["value"];
// cache
const orders = ref<Map<string, { const orders = ref<Map<string, {
loading: boolean, loading: boolean,
value?: order value?: Order
}>>(new Map()); }>>(new Map());
for (const i of clientOrders.value) for (const i of clientOrders.value)
@ -34,9 +39,9 @@ async function loadOrder(id: string) {
if (!entry) throw createError(`excepted order entry for ${id}`); if (!entry) throw createError(`excepted order entry for ${id}`);
entry.loading = true; entry.loading = true;
// @ts-expect-error // @ts-expect-error
entry.value = await $fetch(`/api/orders/${id}` as "/api/order/:id", { entry.value = await fetch(`/api/orders/${id}` as "/api/order/:id", {
method: "GET", method: "GET",
}); }) as Order;
entry.loading = false; entry.loading = false;
} }
@ -51,10 +56,10 @@ function updatePagedListVModel(element: Array<string>) {
const editMode = ref<boolean>(route.query?.edit === "1"); const editMode = ref<boolean>(route.query?.edit === "1");
function editorFields(): Array<fieldDefinition> { function editorFields(): Array<fieldDefinition> {
return [ return [
{ key: "name", type: "text", label: "Name", value: client.value.name ?? undefined }, { key: "name", type: "text", label: "Name", value: client.value.name ?? "" },
{ key: "address", type: "text", label: "Address", value: client.value.address ?? undefined }, { key: "address", type: "text", label: "Address", value: client.value.address ?? "" },
{ key: "phone", type: "text", label: "Phone", value: client.value.phone ?? undefined }, { key: "phone", type: "text", label: "Phone", value: client.value.phone ?? "" },
{ key: "email", type: "text", label: "E-mail", value: client.value.email ?? undefined }, { key: "email", type: "text", label: "E-mail", value: client.value.email ?? "" },
]; ];
} }
@ -73,7 +78,7 @@ async function handleSubmit() {
submitting.value = true; submitting.value = true;
normalizeForm(); normalizeForm();
try { try {
const result = await $fetch( const result = await fetch(
`/api/clients/${client.value.id}` as "/api/clients/:id", { `/api/clients/${client.value.id}` as "/api/clients/:id", {
method: "PATCH", method: "PATCH",
body: formData.value, body: formData.value,
@ -184,24 +189,24 @@ function getCreationDate() {
> >
<template #title="i"> <template #title="i">
<VRow> <VRow>
<VCol>{{ new Date(Number(new Snowflake(BigInt(((i.record) as orderSummary).id)).timestamp)).toLocaleDateString() }}</VCol> <VCol>{{ new Date(Number(new Snowflake(BigInt((i.record.id))).timestamp)).toLocaleDateString() }}</VCol>
<VCol>{{ ((i.record) as orderSummary).value }} PLN</VCol> <VCol>{{ i.record.value }} PLN</VCol>
<VCol> <VCol>
{{ ((i.record) as orderSummary).imported_products_count }} {{ i.record.imported_products_count }}
products, products,
{{ ((i.record) as orderSummary).work_count }} {{ i.record.work_count }}
works works
</VCol> </VCol>
</VRow> </VRow>
</template> </template>
<template #text="i"> <template #text="i">
<VProgressLinear <VProgressLinear
:height="orders.get((i.record as orderSummary).id)?.loading ?? true ? undefined : 0" :height="orders.get(i.record.id)?.loading ?? true ? undefined : 0"
absolute absolute
:progress="orders.get((i.record as orderSummary).id)?.loading ?? true" :progress="orders.get(i.record.id)?.loading ?? true"
:indeterminate="orders.get((i.record as orderSummary).id)?.loading ?? true" :indeterminate="orders.get(i.record.id)?.loading ?? true"
/> />
<OrderView :order="(orders.get((i.record as orderSummary).id)?.value as order | undefined)" /> <OrderView :order="orders.get(i.record.id)?.value" />
</template> </template>
</PagedList> </PagedList>
</VCol> </VCol>

View file

@ -1,124 +0,0 @@
<script setup lang="ts">
/* global $fetch */
import { useFetch, createError } from "nuxt/app";
import { ref, Ref } from "vue";
import { definePageMeta } from "~/.nuxt/imports";
import { client as clientType } from "~/utils/types/database";
definePageMeta({ middleware: ["auth"] });
const clientsRequest = await useFetch("/api/clients");
if (clientsRequest.error.value) throw createError(clientsRequest.error.value?.data ?? "");
const clients = clientsRequest.data as Ref<NonNullable<typeof clientsRequest.data.value>>;
const countRequest = await useFetch("/api/clients/count");
if (countRequest.error.value) throw createError(countRequest.error.value?.data ?? "");
const count = countRequest.data as Ref<NonNullable<typeof countRequest.data.value>>;
function rowClicked(client: string, edit = false) {
console.log(client);
}
async function rowDelete(client: string) {
try {
await $fetch<clientType>(`/api/clients/${client}`, {
method: "DELETE",
});
clients.value = clients.value.filter(e => e.id !== client);
count.value.count--;
} catch (e) {
// FIXME: show the error
console.log(e);
}
}
const loadingMore = ref<boolean>(false);
async function loadBefore() {
loadingMore.value = true;
clients.value.push(...await $fetch("/api/clients", {
query: {
before: clients.value[clients.value.length - 1].id,
},
}));
loadingMore.value = false;
}
</script>
<template>
<VOverlay
model-value
origin="top center"
:scrim="false"
height="fit-content"
persistent
no-click-animation
>
<VAlert class="alert">
owowowowowowowowo
</VAlert>
</VOverlay>
<VRow>
<VCol>
<VBreadcrumbs :items="['Clients']" />
<VSpacer />
<div class="text-h4">
There are {{ count?.count }} clients in the database.
</div>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
<VTable>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="client in clients" :key="client.id">
<td @click="() => rowClicked(client.id)">
{{ client.name }}
</td>
<td @click="() => rowClicked(client.id)">
{{ client.address }}
</td>
<td class="buttons">
<div>
<VBtn icon="mdi-pencil" variant="text" @click="() => rowClicked(client.id, true)" />
<VBtn icon="mdi-delete" color="red" variant="text" @click="() => rowDelete(client.id)" />
</div>
</td>
</tr>
</tbody>
</VTable>
</VCard>
<VCol>
<VBtn
v-if="clients.length < count.count"
color="primary"
:loading="loadingMore"
@click="loadBefore"
>
Load more
</VBtn>
</VCol>
</VCol>
</VRow>
</template>
<style scoped>
.buttons {
width: 0;
padding-right: 4px !important;
}
.buttons > div {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
</style>

View file

@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
/* global $fetch */ import { type NuxtError } from "nuxt/app";
import { useFetch, createError, navigateTo, NuxtError, useRoute } from "nuxt/app"; import { ref, type Ref, reactive } from "vue";
import { ref, Ref, reactive } from "vue";
import { VBtn } from "vuetify/components"; import { VBtn } from "vuetify/components";
import { definePageMeta } from "~/.nuxt/imports";
import { client as clientType } from "~/utils/types/database";
import pagedTable from "~/components/pagedTable.vue"; import pagedTable from "~/components/pagedTable.vue";
import Alerts, { AlertData } from "~/components/alerts.vue"; import Alerts, { type AlertData } from "~/components/alerts.vue";
import { fieldDefinition } from "~/components/entryEditor.vue"; import { type fieldDefinition } from "~/components/entryEditor.vue";
import { useFetch, createError, navigateTo, useRoute, definePageMeta, useRequestFetch } from "#imports";
definePageMeta({ middleware: ["auth"] }); definePageMeta({ middleware: ["auth"] });
const route = useRoute(); const route = useRoute();
const fetch = useRequestFetch();
const alerts = ref<Array<AlertData>>([]); const alerts = ref<Array<AlertData>>([]);
@ -32,7 +32,7 @@ async function rowClicked(client: string, edit = false) {
async function rowDelete(client: string) { async function rowDelete(client: string) {
try { try {
await $fetch<clientType>(`/api/clients/${client}`, { await fetch(`/api/clients/${client}` as "/api/clients/:id", {
method: "DELETE", method: "DELETE",
}); });
clients.value = clients.value.filter(e => e.id !== client); clients.value = clients.value.filter(e => e.id !== client);
@ -48,7 +48,7 @@ async function loadBefore() {
loadingMore.value = true; loadingMore.value = true;
try { try {
clients.value.push(...await $fetch("/api/clients", { clients.value.push(...await fetch("/api/clients", {
query: { query: {
before: clients.value[clients.value.length - 1].id, before: clients.value[clients.value.length - 1].id,
}, },
@ -84,19 +84,21 @@ const formData = ref<any>({
function normalizeForm() { function normalizeForm() {
for (const i in formData.value) for (const i in formData.value)
formData.value[i] = formData.value[i] === "" ? null : formData.value[i]; formData.value[i] = formData.value[i] === "" || formData.value[i] === undefined ? null : formData.value[i];
} }
async function handleSubmit() { async function handleSubmit() {
submitting.value = true; submitting.value = true;
normalizeForm(); normalizeForm();
try { try {
const result = await $fetch( const result = await fetch(
"/api/clients/", { "/api/clients", {
method: "POST", method: "POST",
body: formData.value, body: formData.value,
}, },
); );
clients.value.unshift(result);
count.value.count++;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
submitting.value = false; submitting.value = false;

61
pages/firstRun.vue Normal file
View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue';
import { type NuxtError } from 'nuxt/app';
import { navigateTo, useFetch, definePageMeta, useRequestFetch } from '#imports';
import EntryEditor, { type fieldDefinition } from '~/components/entryEditor.vue';
import Alerts, { type AlertData } from '~/components/alerts.vue';
const editorFields: Array<fieldDefinition> = [
{ key: "username", type: "text", label: "Username", optional: false },
{ key: "password", type: "password", label: "Password", optional: false },
{ key: "email", type: "text", label: "email", optional: false },
];
const formValue = ref<any>({});
const alerts = ref<Array<AlertData>>([]);
definePageMeta({
layout: false,
});
async function submit() {
try {
await useRequestFetch()("/api/firstRun", {
body: formValue.value,
method: "POST",
});
await navigateTo("/login");
} catch (e) {
console.log(e);
alerts.value.push({ text: (e as NuxtError).data.message });
}
}
if (!(await useFetch("/api/firstRun")).data.value)
await navigateTo("/login");
</script>
<template>
<Alerts :alerts="alerts" />
<VCard max-width="450px" class="mx-auto mt-16" variant="outlined">
<template #title>
Initial setup
</template>
<template #text>
<p>
It looks like you've run the server with an empty or uninitialized database or with database without any users.<br>
Below you can initialize the database register your first user and.
</p><br>
<EntryEditor
:fields="editorFields"
@update-sub-model-value="(k, v) => { formValue[k] = v }"
/>
</template>
<template #actions>
<VBtn color="primary" @click="submit">
Initialize
</VBtn>
</template>
</VCard>
</template>

View file

@ -1,28 +0,0 @@
<script setup lang="ts">
/* global $fetch */
import { ref } from 'vue';
const question = ref();
const answer = ref();
async function getAnswer() {
const message = await $fetch("/api/echo", {
body: question.value,
method: "POST",
});
answer.value = message;
}
</script>
<template>
<VCard width="400px">
<template #text>
<VTextarea v-model="question" /><br>
<p>{{ answer }}</p>
<VBtn @click="getAnswer">
Send
</VBtn>
</template>
</VCard>
</template>

View file

@ -1,23 +1,9 @@
<script setup> <script setup lang="ts">
import Test from '~/components/test.vue'; import { navigateTo } from '#imports';
navigateTo("/clients");
</script> </script>
<template> <template>
<h1>Hi mom!</h1> <VBtn @click="navigateTo('/clients')" />
<br>
<Test />
<Test />
<Test />
<Test />
<Test /><br>
<VCard
text="..."
variant="outlined"
/>
</template> </template>
<style scoped>
h1 {
color: blue;
}
</style>

View file

@ -1,35 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
/* global $fetch */
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { VForm } from "vuetify/components"; import { VForm } from "vuetify/components";
import { navigateTo, useCookie, useFetch, useRoute } from "nuxt/app"; import { type CookieRef } from "#app";
import { cookieSettings } from "~/utils/cookieSettings"; import { cookieSettings } from "~/utils/cookieSettings";
import { definePageMeta } from "~/.nuxt/imports"; import { definePageMeta, navigateTo, useCookie, useFetch, useRoute, useRequestFetch, useRequestEvent } from "#imports";
const route = useRoute(); const route = useRoute();
const fetch = useRequestFetch();
const login = ref(""); const login = ref("");
const password = ref(""); const password = ref("");
const loading = ref(false); const loading = ref(false);
const error = ref<true | string>(true); const error = ref<true | string>(true);
const form = ref<VForm | null>(null); const form = ref<VForm | null>(null);
const loggedIn = ref<boolean>(useCookie("token", cookieSettings).value != null); const tokenCookie = useCookie("token", cookieSettings) as CookieRef<string | undefined>;
const loggedIn = ref<boolean>(tokenCookie.value !== undefined);
watch(tokenCookie, (v) => { loggedIn.value = v !== undefined; });
const redirectTo = ref(route.redirectedFrom); const redirectTo = ref(route.redirectedFrom);
definePageMeta({ definePageMeta({
layout: false, layout: false,
middleware: ["first-run"],
}); });
async function submit() { async function submit() {
loading.value = true; loading.value = true;
try { try {
const result = await $fetch("/api/login", { const result = await fetch("/api/login", {
body: { login: login.value, password: password.value }, body: { login: login.value, password: password.value },
method: "POST", method: "POST",
}); });
console.log(result); console.log(result);
loggedIn.value = true; tokenCookie.value = result.token;
password.value = ""; password.value = "";
} catch (e) { } catch (e) {
console.log(typeof e); console.log(typeof e);
@ -44,9 +48,10 @@ async function submit() {
async function logout() { async function logout() {
try { try {
await $fetch("/api/logout"); await fetch("/api/logout");
loggedIn.value = false; loggedIn.value = false;
} catch (e) { } catch (e) {
tokenCookie.value = undefined;
console.error(e); console.error(e);
} }
} }
@ -56,7 +61,7 @@ watch(loggedIn, updateUserInfo);
async function updateUserInfo() { async function updateUserInfo() {
if (loggedIn.value) { if (loggedIn.value) {
try { try {
userInfo.value = JSON.stringify(await $fetch("/api/users/me")); userInfo.value = JSON.stringify(await fetch("/api/users/me"));
} catch (e) { } catch (e) {
// expected if the user is not logged in // expected if the user is not logged in
userInfo.value = ""; userInfo.value = "";
@ -66,7 +71,7 @@ async function updateUserInfo() {
} }
} }
updateUserInfo(); await updateUserInfo();
</script> </script>
<template> <template>

194
pages/orders.vue Normal file
View file

@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, type Ref } from "vue";
import { VBtn } from "vuetify/components";
import type { NuxtError } from "#app";
import Alerts, { type AlertData } from "~/components/alerts.vue";
import { type fieldDefinition } from "~/components/entryEditor.vue";
import { definePageMeta, useFetch, createError, useRoute, navigateTo, useRequestFetch } from "#imports";
definePageMeta({ middleware: ["auth"] });
const route = useRoute();
const fetch = useRequestFetch();
const alerts = ref<Array<AlertData>>([]);
const ordersRequest = await useFetch("/api/orders");
if (ordersRequest.error.value) throw createError(ordersRequest.error.value?.data ?? "");
const orders = ordersRequest.data as Ref<NonNullable<typeof ordersRequest.data.value>>;
const countRequest = await useFetch("/api/orders/count");
if (countRequest.error.value) throw createError(countRequest.error.value?.data ?? "");
const count = countRequest.data as Ref<NonNullable<typeof countRequest.data.value>>;
const createMode = ref<boolean>(route.query?.create === "1");
async function rowClicked(client: string, edit = false) {
await navigateTo({
path: `/order/${client}`,
query: { edit: edit ? 1 : undefined },
});
}
async function rowDelete(client: string) {
try {
await fetch(`/api/orders/${client}` as "/api/orders/:id", {
method: "DELETE",
});
orders.value = orders.value.filter(e => e.id !== client);
count.value.count--;
} catch (e) {
alerts.value.push({ text: (e as NuxtError).message, type: "error" });
console.log(e);
}
}
const loadingMore = ref<boolean>(false);
async function loadBefore() {
loadingMore.value = true;
try {
orders.value.push(...await fetch("/api/orders", {
query: {
before: orders.value[orders.value.length - 1].id,
},
}));
} catch (e) {
alerts.value.push({ text: (e as NuxtError).message });
console.error(e);
}
loadingMore.value = false;
}
function editorFields(): fieldDefinition[] {
return [
{ key: "clientId", type: "client", label: "Client" },
{ key: "draft", type: "boolean", label: "Draft", value: true },
{ key: "imported_products", type: "none", label: "imported_produts - TBA" },
{ key: "work", type: "none", label: "work - TBA" },
];
}
const submitting = ref<boolean>(false);
// const updateForm = ref<VForm | null>(null);
const formButton = ref<VBtn | null>(null);
const formData = ref<any>({});
function normalizeForm() {
for (const i in formData.value)
formData.value[i] = formData.value[i] === "" ? null : formData.value[i];
formData.value.imported_products = [];
formData.value.work = [];
}
async function handleSubmit() {
submitting.value = true;
normalizeForm();
try {
const result = await fetch(
"/api/orders", {
method: "POST",
body: formData.value,
},
);
orders.value.unshift({
...result,
imported_products_count: result.imported_products.length,
work_count: result.work.length,
// NOTE: currently all newly created orders on this page are valued zero
value: 0,
});
count.value.count++;
} catch (e) {
console.error(e);
submitting.value = false;
return;
}
submitting.value = false;
createMode.value = false;
}
</script>
<template>
<Alerts :alerts="alerts" />
<VDialog
v-model="createMode"
:persistent="submitting"
:activator="formButton as unknown as (Element | null) ?? undefined"
width="auto"
>
<VCard width="400px" :loading="submitting">
<VCardTitle>
Create client
</VCardTitle>
<VForm
ref="updateForm"
:disabled="submitting"
class="px-4"
>
<EntryEditor
:fields="editorFields()"
@update-sub-model-value="(k, v) => { formData[k] = v; }"
/>
</VForm>
<VCardActions>
<VBtn
color="primary"
@click="handleSubmit"
>
Submit
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VRow>
<VCol>
<VBreadcrumbs :items="['Orders']" />
<VSpacer />
<div class="text-h4">
There are {{ count?.count }} orders in the database.
</div>
<VBtn
ref="formButton"
>
Create
</VBtn>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
<VTable>
<thead>
<tr>
<th>Client</th>
<th>Draft</th>
<th />
</tr>
</thead>
<pagedTable
buttons
:records="orders"
:fields="['client', 'Draft']"
record-key="id"
@click="(id) => rowClicked(id, false)"
@click:edit="(id) => rowClicked(id, true)"
@click:delete="(id) => rowDelete(id)"
/>
</VTable>
</VCard>
<VCol>
<VBtn
v-if="orders.length < count.count"
color="primary"
:loading="loadingMore"
@click="loadBefore"
>
Load more
</VBtn>
</VCol>
</VCol>
</VRow>
</template>

View file

@ -1,50 +0,0 @@
<script setup lang="ts">
import { useFetch } from '#app';
import { ref } from 'vue';
const tableContent = ref(await useFetch("/api/dbtest").data);
</script>
<template>
<v-card width="min-content">
<v-table>
<thead>
<tr>
<th>id</th>
<th>nazwa</th>
<th>adres</th>
<th>kontakt</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="row in tableContent" :key="row.id">
<td>{{ row.id }}</td>
<td>{{ row.nazwa }}</td>
<td>{{ row.adres }}</td>
<td>{{ row.kontakt }}</td>
<td>
<v-btn-group variant="plain">
<v-btn icon="mdi-pencil" color="main" />
<v-btn icon="mdi-delete" color="red" />
</v-btn-group>
</td>
</tr>
<tr class="last">
<td>*</td>
<td><v-text-field label="nazwa" density="compact" /></td>
<td><v-text-field label="adres" density="compact" /></td>
<td><v-text-field label="kontakt" density="compact" /></td>
<td><v-btn icon="mdi-plus" variant="plain" class="rounded" /></td>
</tr>
</tbody>
</v-table>
</v-card>
</template>
<style scoped>
.last td:not(:last-child):not(:first-child) {
padding-left: 0;
padding-right: 0;
}
</style>

View file

@ -6,8 +6,6 @@ import { defineNuxtPlugin } from '#app';
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
const vuetify = createVuetify({ const vuetify = createVuetify({
ssr: true, ssr: true,
components,
directives,
theme: { theme: {
}, },
}); });

118
schema.prisma Normal file
View file

@ -0,0 +1,118 @@
datasource db {
provider = "mysql"
url = env("DB_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id BigInt @id @unique @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
username String @unique @db.VarChar(30)
email String @unique @db.VarChar(128)
password Bytes @db.Binary(64)
display_name String? @db.VarChar(30)
managedOrders Order[]
sessions Session[]
@@map("users")
}
model Session {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
userId BigInt @map("user") @db.UnsignedBigInt
sessionToken Bytes @db.Binary(64)
expiry_date DateTime? @default(dbgenerated("(now() + interval 30 day)")) @db.Timestamp(0)
user User @relation(fields: [userId], references: [id])
@@index([userId], map: "user_idx")
@@index([sessionToken])
@@map("sessions")
}
model Client {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
name String? @db.VarChar(128)
address String? @db.VarChar(128)
phone String? @db.VarChar(16)
email String? @db.VarChar(128)
orders Order[]
@@map("clients")
}
model Order {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
clientId BigInt @db.UnsignedBigInt @map("client")
userId BigInt @db.UnsignedBigInt @map("user")
draft Boolean @default(true) @map("is_draft") @db.TinyInt
imported_products ImportedProduct[]
client Client @relation(fields: [clientId], references: [id])
user User @relation(fields: [userId], references: [id])
work Work[]
@@index([clientId])
@@index([userId])
@@map("orders")
}
model ImportedProduct {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
orderId BigInt @db.UnsignedBigInt @map("order")
name String? @db.VarChar(128)
link String @db.VarChar(1024)
price_imported Decimal @default(0.00) @db.Decimal(10, 2)
price Decimal @default(0.00) @db.Decimal(10, 2)
order Order @relation(fields: [orderId], references: [id])
@@index([orderId])
@@map("imported_products")
}
model Offer {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
name String @db.VarChar(45)
description String? @db.Text
recommended_price Decimal? @db.Decimal(10, 2)
work Work[]
@@map("offer")
}
model OrderTemplate {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
name String @db.VarChar(45)
description String? @db.Text
work_templates WorkTemplate[]
@@map("order_templates")
}
model Work {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
orderId BigInt @map("order") @db.UnsignedBigInt
offerId BigInt @map("offer") @db.UnsignedBigInt
price Decimal @db.Decimal(10, 2)
notes String? @db.Text
fulfilled Boolean @default(false) @map("is_fulfilled") @db.TinyInt
order Order @relation(fields: [orderId], references: [id])
offer Offer @relation(fields: [offerId], references: [id])
@@index([offerId], map: "offer_idx")
@@index([orderId], map: "order_idx")
@@map("work")
}
model WorkTemplate {
id BigInt @id @default(dbgenerated("(((unix_timestamp() * 1000) * pow(2,22)) + floor((rand() * pow(2,12))))")) @db.UnsignedBigInt
orderTemplateId BigInt @map("order_template") @db.UnsignedBigInt
offerId BigInt @map("offer") @db.UnsignedBigInt
price Decimal @default(0.00) @db.Decimal(10, 2)
notes String? @db.Text
orderTemplate OrderTemplate @relation(fields: [orderTemplateId], references: [id])
@@index([offerId])
@@index([orderTemplateId])
@@map("work_templates")
}

Binary file not shown.

View file

@ -1,10 +1,10 @@
/* global defineEventHandler getQuery, createError */ import { defineEventHandler } from "h3";
import BaaPagination from "~/server/utils/baaPagination"; import getPaginatedParameters from "../utils/baaPageParsing";
import { client } from "~/utils/types/database"; import { database } from "../utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
export const baaWrapper = new BaaPagination<client, "id">("clients", "id");
export default defineEventHandler((e) => { export default defineEventHandler((e) => {
return baaWrapper.RESTget(e); const pageParameters = getPaginatedParameters(e, 50, 200);
return database.client.findPaginated(pageParameters, {}).then(prismaToWeb);
}); });

View file

@ -1,7 +1,12 @@
/* global defineEventHandler, createError, readBody */ import { defineEventHandler, readBody, setResponseStatus } from "h3";
import { type Client } from "@prisma/client";
import { baaWrapper } from "./clients.get"; import getRequestingUser from "../utils/getRequestingUser";
import { client } from "~/utils/types/database"; import { database } from "../utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import Snowflake from "~/utils/snowflake";
import { createError } from "#imports";
const clientKeys: Array<string> = [ const clientKeys: Array<string> = [
"name", "name",
@ -10,10 +15,10 @@ const clientKeys: Array<string> = [
"email", "email",
]; ];
export function checkIsClient( export function checkIsClient<Patch extends boolean = boolean>(
value: any, value: any,
required = false, patch: Patch,
): value is Partial<Omit<client, "id">> { ): value is Patch extends true ? Partial<Omit<Client, "id">> : Omit<Client, "id"> {
const errors = new Map<string, string>(); const errors = new Map<string, string>();
if (typeof value !== "object") { if (typeof value !== "object") {
@ -23,12 +28,12 @@ export function checkIsClient(
}); });
} }
if (!(typeof value.name === "string" || value.name === null || (!required && value.name === undefined))) errors.set("name", "is not string or null"); if (!(typeof value.name === "string" || value.name === null || (patch && value.name === undefined))) errors.set("name", "is not string or null");
if (!(typeof value.address === "string" || value.address === null || (!required && value.address === undefined))) errors.set("address", "is not string or null"); if (!(typeof value.address === "string" || value.address === null || (patch && value.address === undefined))) errors.set("address", "is not string or null");
if (!(typeof value.phone === "string" || value.phone === null || (!required && value.phone === undefined))) errors.set("phone", "is not string or null"); if (!(typeof value.phone === "string" || value.phone === null || (patch && value.phone === undefined))) errors.set("phone", "is not string or null");
if (!(typeof value.email === "string" || value.email === null || (!required && value.email === undefined))) errors.set("email", "is not string or null"); if (!(typeof value.email === "string" || value.email === null || (patch && value.email === undefined))) errors.set("email", "is not string or null");
for (const i in value as Partial<Omit<client, "id">>) for (const i in value as Partial<Omit<Client, "id">>)
if (!clientKeys.includes(i)) errors.set(i, `excessive property`); if (!clientKeys.includes(i)) errors.set(i, `excessive property`);
if (errors.size !== 0) { if (errors.size !== 0) {
@ -48,6 +53,20 @@ export function checkIsClient(
return true; return true;
} }
export default defineEventHandler((e) => { export default defineEventHandler(async (e) => {
return baaWrapper.RESTpost(e, clientKeys as Array<keyof Omit<client, "id">>, (o): o is Omit<client, "id"> => checkIsClient(o, true)); const body = await readBody(e);
const id = new Snowflake().state;
const user = await getRequestingUser(e);
if (!checkIsClient(body, false)) throw createError({ message: "Invalid body", statusCode: 400 });
const rvalue = await database.client.create({
data: {
...body,
id,
},
});
setResponseStatus(e, 201);
return prismaToWeb(rvalue);
}); });

View file

@ -1,7 +1,22 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
import { baaWrapper } from "../clients.get"; import { database } from "~/server/utils/database";
export default defineEventHandler((e) => { import { createError } from "#imports";
return baaWrapper.RESTdeleteRecord(e);
export default defineEventHandler(async (e) => {
const id = e.context.params?.id as string;
try {
await database.client.delete({
where: {
id: BigInt(id),
},
});
} catch (e) {
// FIXME: should be 500 on errors other than "RecordNotFound"
throw createError({ statusCode: 404 });
}
return null;
}); });

View file

@ -1,7 +1,18 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
import { baaWrapper } from "../clients.get"; import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
export default defineEventHandler((e) => { import { createError } from "#imports";
return baaWrapper.RESTgetRecord(e);
export default defineEventHandler(async (e) => {
const key = e.context.params?.id as string;
const rvalue = await database.client.findUnique({
where: {
id: BigInt(key),
},
});
if (!rvalue) throw createError({ statusCode: 404 });
return prismaToWeb(rvalue);
}); });

View file

@ -1,8 +1,23 @@
/* global defineEventHandler */ import { defineEventHandler, readBody } from "h3";
import { checkIsClient } from "../clients.post"; import { checkIsClient } from "../clients.post";
import { baaWrapper } from "../clients.get"; import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
export default defineEventHandler((e) => { import { createError } from "#imports";
return baaWrapper.RESTpatchRecord(e, checkIsClient);
export default defineEventHandler(async (e) => {
const body = await readBody(e);
const id = e.context.params?.id as string;
if (!checkIsClient(body, true)) throw createError({ message: "Invalid body", statusCode: 400 });
const rvalue = await database.client.update({
where: {
id: BigInt(id),
},
data: body,
});
return prismaToWeb(rvalue);
}); });

View file

@ -1,9 +1,16 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
import { baaWrapper } from "~/server/api/orders.get"; import { getOrders } from "~/server/api/orders.get";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import getPaginatedParameters from "~/server/utils/baaPageParsing";
export default defineEventHandler(async (e) => { export default defineEventHandler((e) => {
const baa = await baaWrapper.RESTget(e, 50, 200, "`client` = ?", [e.context.params?.id]); const pageParameters = getPaginatedParameters(e, 50, 200);
console.log(baa); const clientId = e.context.params?.id as string;
return baa; return getOrders(
pageParameters,
{
clientId: BigInt(clientId),
},
).then(prismaToWeb);
}); });

View file

@ -1,7 +1,9 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
import { baaWrapper } from "../clients.get"; import { database } from "~/server/utils/database";
export default defineEventHandler((e) => { export default defineEventHandler(async (e) => {
return baaWrapper.RESTrecordCount(e); return {
count: await database.client.count({}),
};
}); });

View file

@ -1,9 +0,0 @@
/* global defineEventHandler */
import { database } from "../utils/database";
export default defineEventHandler(async () => {
const [owo] = await database.execute("SELECT * FROM `sch_baza_smartfony`.`lombardy`");
return owo as {id: number, nazwa: string, adres: string, kontakt: string}[];
});

View file

@ -1,26 +0,0 @@
/* global defineEventHandler, readBody */
import { RowDataPacket } from "mysql2";
import { database } from "../utils/database";
import { isString } from "../utils/isString";
export default defineEventHandler(async (e) => {
const data = await readBody(e);
const nazwa = data.nazwa;
const adres = data.adres;
const kontakt = data.kontakt;
if (!isString(nazwa)) throw new Error("nazwa is not string");
if (!isString(adres)) throw new Error("adres is not string");
if (!isString(kontakt)) throw new Error("kontakt is not string");
const [inserted] = await database.query("INSERT INTO `sch_baza_smartfony`.`lombardy` (`nazwa`, `adres`, `kontakt`) VALUES (?, ?, ?);", [nazwa, adres, kontakt]) as RowDataPacket[];
return {
id: inserted.insertId as number,
nazwa,
adres,
kontakt,
};
});

View file

@ -1,9 +0,0 @@
/* global defineEventHandler */
import { database } from "~/server/utils/database";
export default defineEventHandler(async (e) => {
if (!e.context.params?.id) return Error("id is not provided");
const rowID = e.context.params.id;
await database.execute("DELETE FROM `sch_baza_smartfony`.`lombardy` WHERE `id` = ?", [rowID]);
});

View file

@ -1,4 +1,4 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const message = event.node.req.read(); const message = event.node.req.read();

View file

@ -0,0 +1,16 @@
import { defineEventHandler } from "h3";
import { database } from "../utils/database";
export async function isFirstRun() {
try {
const numberOfUsers = await database.user.count();
return numberOfUsers === 0;
} catch {
// We could fall here if the database is not initialized
return true;
}
}
export default defineEventHandler((e) => {
return isFirstRun();
});

View file

@ -0,0 +1,52 @@
import url from "node:url";
import path from "node:path";
import { defineEventHandler, setResponseStatus, readBody } from "h3";
// eslint-disable-next-line import/default
import PrismaMigrate from "@prisma/migrate";
import { database } from "../utils/database";
import { isFirstRun } from "./firstRun.get";
import { getPasswordHash } from "./login.post";
import Snowflake from "~/utils/snowflake";
import { createError } from "#imports";
export default defineEventHandler(async (e) => {
if (!isFirstRun()) {
setResponseStatus(e, 404);
return null;
}
const body = await readBody(e);
if (typeof body !== "object") throw createError({ message: "Invalid body", statusCode: 400 });
const username = body.username;
if (typeof username !== "string") throw createError({ message: "username is not string", statusCode: 400 });
const password = body.password;
if (typeof password !== "string") throw createError({ message: "password is not string", statusCode: 400 });
const email = body.email;
if (typeof email !== "string") throw createError({ message: "email is not string", statusCode: 400 });
const dbPushParam = [
"--force-reset",
"--skip-generate",
];
if (!import.meta.dev ?? true) {
const mainPath = path.dirname(url.fileURLToPath(import.meta.url));
dbPushParam.push(
"--schema",
`${mainPath}/node_modules/.prisma/client/schema.prisma`,
);
}
await (new PrismaMigrate.DbPush()).parse(dbPushParam);
await database.user.create({
data: {
id: new Snowflake().state,
username,
email,
password: getPasswordHash(password),
},
});
return null;
});

View file

@ -1,4 +1,4 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
export default defineEventHandler(() => { export default defineEventHandler(() => {
return "Hi mom!"; return "Hi mom!";

View file

@ -1,10 +1,18 @@
/* global defineEventHandler, getCookie, setCookie, readBody, createError */
import crypto from "crypto"; import crypto from "crypto";
import { defineEventHandler, getCookie, setCookie, readBody } from "h3";
import { database, data } from "../utils/database"; import { database } from "../utils/database";
import { isString } from "../utils/isString"; import { isString } from "../utils/isString";
import { cookieSettings } from "../utils/rootUtils"; import { cookieSettings } from "../utils/rootUtils";
import Snowflake from "~/utils/snowflake"; import SessionToken from "../utils/SessionToken";
import { createError } from "#imports";
export function getPasswordHash(password: string) {
return crypto.createHmac("sha512", "42")
.update(password)
.digest();
}
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
if (getCookie(e, "token")) if (getCookie(e, "token"))
@ -18,23 +26,25 @@ export default defineEventHandler(async (e) => {
if (!isString(login)) throw createError({ statusCode: 400, message: "Login is not string." }); if (!isString(login)) throw createError({ statusCode: 400, message: "Login is not string." });
if (!isString(password)) throw createError({ statusCode: 400, message: "Password is not string." }); if (!isString(password)) throw createError({ statusCode: 400, message: "Password is not string." });
const hashedPassword = crypto.createHmac("sha512", "42") const hashedPassword = getPasswordHash(password);
.update(password)
.digest("hex");
const [account] = await database.query( const account = await database.user.findUnique({
"SELECT CONVERT(`id`, CHAR(32)) AS `id` from `users` WHERE `username` = ? AND LOWER(HEX(`password`)) = ? LIMIT 1", where: {
[login, hashedPassword], username: login,
)as unknown as data<{id: string}>; password: hashedPassword,
},
select: {
id: true,
},
});
if (account.length === 0) throw createError({ statusCode: 400, message: "Invalid username or password." }); if (account === null) throw createError({ statusCode: 400, message: "Invalid username or password." });
const sessionId = new Snowflake().toString(); const session = new SessionToken(account.id);
await database.query( await database.session.create({
"INSERT INTO `sessions` (`id`, `user`) VALUES ( ? , ? )", data: session.toPrisma(),
[sessionId, account[0].id], });
); setCookie(e, "token", session.toString(), cookieSettings);
setCookie(e, "token", sessionId, cookieSettings); return { message: "Login successful", token: session.toString() };
return { message: "Login successful", token: sessionId };
}); });

View file

@ -1,9 +1,11 @@
/* global defineEventHandler, createError, getCookie, deleteCookie */ import { defineEventHandler, getCookie, deleteCookie } from "h3";
import { isAuthorised } from "../middleware/auth"; import { isAuthorised } from "../middleware/auth";
import { database } from "../utils/database"; import { database } from "../utils/database";
import { cookieSettings } from "../utils/rootUtils"; import { cookieSettings } from "../utils/rootUtils";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const token = getCookie(e, "token"); const token = getCookie(e, "token");
if (token === undefined) { if (token === undefined) {
@ -21,9 +23,10 @@ export default defineEventHandler(async (e) => {
}); });
} }
database.query( database.session.delete({
"DELETE FROM `sessions` WHERE `id` = ?", where: {
[token], id: BigInt(token),
); },
});
return { message: "Logged out" }; return { message: "Logged out" };
}); });

View file

@ -1,37 +1,65 @@
/* global defineEventHandler */ import { defineEventHandler } from "h3";
import { type Order, type Client, Prisma } from "@prisma/client";
import BaaPagination from "../utils/baaPagination"; import getPaginatedParameters, { type pageData } from "../utils/baaPageParsing";
import { data, database } from "../utils/database"; import { database } from "../utils/database";
import { client, orderSummary } from "~/utils/types/database"; import { prismaToWeb } from "~/server/utils/prismaToWeb";
export const baaWrapper = new BaaPagination<orderSummary, "id">( type orderSummary = Omit<Order, "clientId"> & {
"orderSummaries", client: Client;
"id", value: number;
"*, CONVERT(`client`, CHAR) AS `client`, CONVERT(`user`, CHAR) as `user`", imported_products_count: number;
); work_count: number;
};
export default defineEventHandler(async (e) => { export async function getOrders(
const orders = await baaWrapper.RESTget(e, 50, 200); pageParameters: pageData,
where?: Prisma.OrderWhereInput,
) {
const data = await database.order.findPaginated(
pageParameters,
{
select: {
id: true,
client: true,
userId: true,
draft: true,
imported_products: {
select: {
price: true,
},
},
work: {
select: {
price: true,
},
},
},
where,
},
);
const uniqueClients: Array<string> = []; const rvalue = new Array<orderSummary>();
for (const i of orders) {
if (!uniqueClients.includes(i.client)) for (const i of data) {
uniqueClients.push(database.escape(i.client)); const importedProductsPriceSum = i.imported_products.reduce((pv, cv) => pv + cv.price.toNumber(), 0);
const workPriceSum = i.work.reduce((pv, cv) => pv + cv.price.toNumber(), 0);
rvalue.push({
id: i.id,
client: i.client,
draft: i.draft,
imported_products_count: i.imported_products.length,
userId: i.userId,
value: importedProductsPriceSum + workPriceSum,
work_count: i.work.length,
});
} }
const [clients] = await database.query(
["SELECT",
"*,",
"CONVERT(`id`, CHAR) AS `id`",
"FROM `clients`",
"WHERE `id` IN",
`(${uniqueClients.join(', ')})`,
].join(" "),
) as data<client>;
const rvalue: Array<Omit<typeof orders, "client"> | { client?: client }> = [];
for (const i of orders)
rvalue.push({ ...i, client: clients.find(e => i.client === e.id) });
return rvalue; return rvalue;
}
export default defineEventHandler((e) => {
const pageParameters = getPaginatedParameters(e, 50, 200);
return getOrders(pageParameters, {}).then(prismaToWeb);
}); });

View file

@ -1,37 +1,44 @@
/* global defineEventHandler, createError, readBody, setResponseStatus */ import { defineEventHandler, readBody, setResponseStatus } from "h3";
import * as Prisma from "@prisma/client";
import { createValidationError, handleRecursedValidationError } from "../utils/validation"; import { createValidationError, handleRecursedValidationError } from "../utils/validation";
import { database as db } from "../utils/database"; import { database } from "../utils/database";
import getRequestingUser from "../utils/getRequestingUser"; import getRequestingUser from "../utils/getRequestingUser";
import { getOrder } from "./orders/[id].get"; import { getOrder } from "./orders/[id].get";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import Snowflake from "~/utils/snowflake"; import Snowflake from "~/utils/snowflake";
type importedProduct = { import { createError } from "#imports";
type importedProduct<inOrder extends boolean = boolean> = {
orderId: inOrder extends true ? never : string,
name: string | null, name: string | null,
link: string, link: string,
price_imported: number, price_imported: number,
price: number, price: number,
} }
type work = { type work<inOrder extends boolean = boolean> = {
offer: string, orderId: inOrder extends true ? never : string,
offerId: string,
price: number, price: number,
notes: string | null, notes: string | null,
is_fulfilled: boolean | 0 | 1, fulfilled: boolean,
} }
type order = { type order = {
client: string, clientId: string,
// user: string, // userId: string,
is_draft: boolean | 0 | 1, draft: boolean,
imported_products: Array<importedProduct>, imported_products: Array<importedProduct<true>>,
work: Array<work>, work: Array<work<true>>,
}; };
export function checkIsWork<Patch extends boolean = boolean>( export function checkIsWork<Patch extends boolean = boolean, inOrder extends boolean = boolean>(
value: any, value: any,
patch: Patch, patch: Patch,
): value is Patch extends true ? Partial<work> : work { needsOrderId: inOrder,
): value is Patch extends true ? Partial<work<inOrder>> : work<inOrder> {
const errors = new Map<string, string>(); const errors = new Map<string, string>();
if (typeof value !== "object") { if (typeof value !== "object") {
@ -41,19 +48,24 @@ export function checkIsWork<Patch extends boolean = boolean>(
}); });
} }
if (!(typeof value.offer === "string" || (patch && value.offer === undefined))) errors.set("offer", "is not string"); if (!(typeof value.orderId === "string" || (patch && value.orderId === undefined) || !needsOrderId)) errors.set("orderId", "is not string");
if (!(typeof value.offerId === "string" || (patch && value.offerId === undefined))) errors.set("offerId", "is not string");
if (!(typeof value.price === "number" || (patch && value.price === undefined))) errors.set("price", "is not price"); if (!(typeof value.price === "number" || (patch && value.price === undefined))) errors.set("price", "is not price");
if (!(typeof value.notes === "string" || value.notes === null || (patch && value.notes === undefined))) errors.set("notes", "is not string or null"); if (!(typeof value.notes === "string" || value.notes === null || (patch && value.notes === undefined))) errors.set("notes", "is not string or null");
if (!(typeof value.is_fulfilled === "boolean" || value.is_fulfilled === 0 || value.is_fulfilled === 1 || (patch && value.is_fulfilled === undefined))) errors.set("is_fulfilled", "is not boolean"); if (!(typeof value.is_fulfilled === "boolean" || (patch && value.is_fulfilled === undefined))) errors.set("is_fulfilled", "is not boolean");
// TODO: Excessive property checking
// Excessive properties should be checked and an error should be thrown if there is one
if (errors.size !== 0) throw createValidationError(errors); if (errors.size !== 0) throw createValidationError(errors);
return true; return true;
} }
export function checkIsImportedProduct<Patch extends boolean = boolean>( export function checkIsImportedProduct<Patch extends boolean = boolean, inOrder extends boolean = boolean>(
value: any, value: any,
patch: Patch, patch: Patch,
): value is Patch extends true ? Partial<importedProduct> : importedProduct { needsOrderId: inOrder,
): value is Patch extends true ? Partial<importedProduct<inOrder>> : importedProduct<inOrder> {
const errors = new Map<string, string>(); const errors = new Map<string, string>();
if (typeof value !== "object") { if (typeof value !== "object") {
@ -63,10 +75,14 @@ export function checkIsImportedProduct<Patch extends boolean = boolean>(
}); });
} }
if (!(typeof value.orderId === "string" || (patch && value.orderId === undefined) || !needsOrderId)) errors.set("orderId", "is not string");
if (!(typeof value.name === "string" || value.name === null || (patch && value.name === undefined))) errors.set("name", "is not string or null"); if (!(typeof value.name === "string" || value.name === null || (patch && value.name === undefined))) errors.set("name", "is not string or null");
if (!(typeof value.link === "string" || (patch && value.name === undefined))) errors.set("link", "is not string"); if (!(typeof value.link === "string" || (patch && value.name === undefined))) errors.set("link", "is not string");
if (!(typeof value.price_imported === "number" || (patch && value.name === undefined))) errors.set("price_imported", "is not number"); if (!(typeof value.price_imported === "number" || (patch && value.name === undefined))) errors.set("price_imported", "is not number");
if (!(typeof value.price || (patch && value.price === undefined))) errors.set("price", "is not number"); if (!(typeof value.price === "number" || (patch && value.price === undefined))) errors.set("price", "is not number");
// TODO: Excessive property checking
// Excessive properties should be checked and an error should be thrown if there is one
if (errors.size !== 0) throw createValidationError(errors); if (errors.size !== 0) throw createValidationError(errors);
@ -76,7 +92,7 @@ export function checkIsImportedProduct<Patch extends boolean = boolean>(
export function checkIsOrder<Patch extends boolean = boolean>( export function checkIsOrder<Patch extends boolean = boolean>(
value: any, value: any,
patch: Patch, patch: Patch,
): value is Patch extends true ? Partial<Pick<order, "client" | "is_draft">> : order { ): value is Patch extends true ? Partial<Omit<order, "imported_products" | "work">> : order {
const errors = new Map<string, string>(); const errors = new Map<string, string>();
if (typeof value !== "object") { if (typeof value !== "object") {
@ -86,19 +102,22 @@ export function checkIsOrder<Patch extends boolean = boolean>(
}); });
} }
if (!(typeof value.client === "string" || (patch && value.client === undefined))) errors.set("client", "is not string"); if (!(typeof value.clientId === "string" || (patch && value.clientId === undefined))) errors.set("clientId", "is not string");
if (!(typeof value.is_draft === "boolean" || value.is_draft === 0 || value.is_draft === 1 || (patch && value.is_draft === undefined))) errors.set("is_draft", "is not boolean"); if (!(typeof value.draft === "boolean" || (patch && value.is_draft === undefined))) errors.set("draft", "is not boolean");
if (!(value.imported_products instanceof Array)) errors.set("imported_products", "is not array"); if (!(value.imported_products instanceof Array)) errors.set("imported_products", "is not array");
else if (patch && value.imported_products !== undefined) errors.set("imported_products", "cannot patch from order"); else if (patch && value.imported_products !== undefined) errors.set("imported_products", "cannot patch from order");
if (!(value.work instanceof Array)) errors.set("work", "is not array"); if (!(value.work instanceof Array)) errors.set("work", "is not array");
else if (patch && value.work !== undefined) errors.set("work", "cannot patch from order"); else if (patch && value.work !== undefined) errors.set("work", "cannot patch from order");
// TODO: Excessive property checking
// Excessive properties should be checked and an error should be thrown if there is one
if (!patch) { if (!patch) {
const importedProducts = value.imported_products; const importedProducts = value.imported_products;
if (importedProducts instanceof Array) { if (importedProducts instanceof Array) {
for (const i in importedProducts) { for (const i in importedProducts) {
try { try {
checkIsImportedProduct(importedProducts[i], patch); checkIsImportedProduct(importedProducts[i], patch, false);
} catch (e) { } catch (e) {
handleRecursedValidationError(e, errors, `imported_products[${i}]`); handleRecursedValidationError(e, errors, `imported_products[${i}]`);
} }
@ -109,7 +128,7 @@ export function checkIsOrder<Patch extends boolean = boolean>(
if (work instanceof Array) { if (work instanceof Array) {
for (const i in work) { for (const i in work) {
try { try {
checkIsWork(work[i], patch); checkIsWork(work[i], patch, false);
} catch (e) { } catch (e) {
handleRecursedValidationError(e, errors, `work[${i}]`); handleRecursedValidationError(e, errors, `work[${i}]`);
} }
@ -124,49 +143,49 @@ export function checkIsOrder<Patch extends boolean = boolean>(
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const body = await readBody(e); const body = await readBody(e);
const id = new Snowflake().toString(); const id = new Snowflake().state;
const user = await getRequestingUser(e); const user = await getRequestingUser(e);
if (!checkIsOrder(body, false)) throw createError({ message: "Invalid body", statusCode: 400 }); if (!checkIsOrder(body, false)) throw createError({ message: "Invalid body", statusCode: 400 });
const database = await db.new(); await database.order.create({
await database.beginTransaction(); data: {
clientId: BigInt(body.clientId),
await database.query( draft: body.draft,
["INSERT INTO", imported_products: {
"`orders`", createMany: {
"VALUES", data: body.imported_products.reduce(
"(?, ?, ?, ?)", (pV, cV) => {
].join(" "), pV.push({
[id, body.client, user.id, body.is_draft], ...cV,
); id: new Snowflake().state,
});
const promises: Array<Promise<any>> = []; return pV;
for (const i of body.imported_products) { },
promises.push(database.query( [] as Array<Omit<Prisma.Prisma.ImportedProductCreateManyOrderInput, "orderId">>,
["INSERT INTO", ),
"`imported_products`", },
"VALUES", },
"(?, ?, ?, ?, ?, ?)", work: {
].join(" "), createMany: {
[new Snowflake().toString(), id, i.name, i.link, i.price_imported, i.price], data: body.work.reduce(
)); (pV, cV) => {
} pV.push({
...cV,
for (const i of body.work) { id: new Snowflake().state,
promises.push(database.query( offerId: BigInt(cV.offerId),
["INSERT INTO", });
"`work`", return pV;
"VALUES", },
"(?, ?, ?, ?, ?, ?)", [] as Array<Omit<Prisma.Prisma.WorkCreateManyOrderInput, "orderId">>,
].join(" "), ),
[new Snowflake().toString(), id, i.offer, i.price, i.notes, i.is_fulfilled], },
)); },
} id,
userId: user.id,
await Promise.all(promises); },
await database.commit(); });
setResponseStatus(e, 201); setResponseStatus(e, 201);
return getOrder(id); return getOrder(id).then(prismaToWeb);
}); });

View file

@ -1,17 +1,22 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { ResultSetHeader } from "mysql2";
import { database } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const id = e.context.params?.id; const id = e.context.params?.id as string;
const [result] = await database.query( try {
"DELETE FROM `orders` WHERE `id` = ?", await database.order.delete({
[id], where: {
) as unknown as [ResultSetHeader]; id: BigInt(id),
},
if (result.affectedRows === 0) throw createError({ statusCode: 404 }); });
} catch (e) {
// FIXME: should be 500 on errors other than "RecordNotFound"
throw createError({ statusCode: 404 });
}
return null; return null;
}); });

View file

@ -1,108 +1,39 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { createError } from "#imports";
import { offer as offerType, order } from "~/utils/types/database"; import { database } from "~/server/utils/database";
import { database, data } from "~/server/utils/database"; import { prismaToWeb } from "~/server/utils/prismaToWeb";
export async function orderExists(id: string) { export async function orderExists(id: bigint) {
const [[exists]] = await database.query( const exists = await database.order.findUnique({
"SELECT EXISTS(*) AS `exists` FROM `orders` WHERE `id` = ?", where: {
[id], id,
) as data<{exists: 0 | 1}>; },
});
return exists.exists === 1; return exists !== null;
} }
export async function getImportedProducts(id: string) { export async function getOrder(id: bigint) {
const [importedProducts] = await database.query( const order = await database.order.findUnique({
["SELECT", where: {
"CONVERT(`id`, CHAR) AS `id`,", id,
"`name`,", },
"`link`,", include: {
"`price`,", imported_products: true,
"`price_imported`", work: {
"FROM `imported_products`", include: {
"WHERE `order` = ?", offer: true,
].join(" "), },
[id], },
) as data<{ client: true,
id: string, },
name: string | null, });
link: string,
price: string,
price_imported: string
}>;
return importedProducts;
}
export async function getWork(id: string) {
const [work] = await database.query(
["SELECT",
"CONVERT(`id`, CHAR) AS `id`,",
"CONVERT(`offer`, CHAR) AS `offer`,",
"`price`,",
"`notes`,",
"`is_fulfilled`",
"FROM `work`",
"WHERE `order` = ?",
].join(" "),
[id],
) as data<{
id: string,
offer: offerType,
price: number,
notes: string | null,
is_fulfilled: 0 | 1,
}>;
const [offer] = await database.query(
["SELECT",
"CONVERT(`offer`.`id`, CHAR) AS `id`,",
"`offer`.`name`,",
"`offer`.`description`,",
"`offer`.`recommended_price`",
"FROM",
"`work`",
"LEFT JOIN `offer` ON `work`.`offer` = `offer`.`id`",
"WHERE `work`.`order` = ?",
].join(" "),
[id],
) as data<offerType>;
// @ts-ignore i.offer is string, but it needs to be an offer object
for (const i of work) i.offer = offer.find(e => e.id === i.offer) as offerType;
return work;
}
export async function getOrder(id: string): Promise<order> {
const [[order]] = await database.query(
["SELECT",
"CONVERT(`id`, CHAR) AS `id`,",
"CONVERT(`client`, CHAR) AS `client`,",
"CONVERT(`user`, CHAR) AS `user`, ",
"`is_draft`,",
"`value`",
"FROM `orderSummaries`",
"WHERE `id` = ?",
].join(" "),
[id],
) as data<{
id: string,
client: string,
user: string,
is_draft: 0 | 1,
value: number,
}>;
if (!order) throw createError({ statusCode: 404 }); if (!order) throw createError({ statusCode: 404 });
return order;
const importedProducts = await getImportedProducts(id);
const work = await getWork(id);
return { ...order, imported_products: importedProducts, work };
} }
export default defineEventHandler((e) => { export default defineEventHandler((e) => {
const key = e.context.params?.id; const key = e.context.params?.id as string;
return getOrder(key as string); return getOrder(BigInt(key)).then(prismaToWeb);
}); });

View file

@ -1,17 +1,27 @@
/* global defineEventHandler, readBody, createError */ import { defineEventHandler, readBody } from "h3";
import { checkIsOrder } from "../orders.post"; import { checkIsOrder } from "../orders.post";
import { database as db } from "~/server/utils/database"; import { getOrder } from "./[id].get";
import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const body = await readBody(e); const body = await readBody(e);
const id = e.context.params?.id; const id = e.context.params?.id as string;
if (!checkIsOrder(e, true)) throw createError({ message: "Invalid body", statusCode: 400 }); if (!checkIsOrder(body, true)) throw createError({ message: "Invalid body", statusCode: 400 });
const database = await db.new(); await database.order.update({
await database.beginTransaction(); where: {
id: BigInt(id),
},
data: {
clientId: body.clientId ? BigInt(body.clientId) : undefined,
draft: body.draft,
},
});
for (const [k, v] of Object.entries(body)) return getOrder(BigInt(id)).then(prismaToWeb);
database.query(`UPDATE TABLE \`orders\` SET \`${k}\` = ? WHERE \`id\` = ?`, [v, id]);
}); });

View file

@ -1,12 +1,27 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { orderExists, getImportedProducts } from "../[id].get"; import { orderExists } from "../[id].get";
import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const id = e.context.params?.id as string; const orderId = e.context.params?.id as string;
if (!orderExists(id)) throw createError({ statusCode: 404 }); if (!(await orderExists(BigInt(orderId)))) throw createError({ statusCode: 404 });
const importedProducts = await getImportedProducts(id); return database.importedProduct.findMany({
return importedProducts; where: {
orderId: BigInt(orderId),
},
select: {
id: true,
link: true,
name: true,
orderId: true,
price: true,
price_imported: true,
},
}).then(prismaToWeb);
}); });

View file

@ -1,27 +1,33 @@
/* global defineEventHandler, readBody, createError, setResponseStatus */ import { defineEventHandler, readBody, setResponseStatus } from "h3";
import { checkIsImportedProduct } from "../../orders.post"; import { checkIsImportedProduct } from "../../orders.post";
import { getImportedProducts, orderExists } from "../[id].get"; import { orderExists } from "../[id].get";
import Snowflake from "~/utils/snowflake"; import Snowflake from "~/utils/snowflake";
import { database } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const body = await readBody(e); const body = await readBody(e);
const idOrder = e.context.params?.id as string; const idOrder = e.context.params?.id as string;
const idImportedProducts = new Snowflake().toString(); const idImportedProduct = new Snowflake().state;
if (!orderExists(idOrder)) throw createError({ statusCode: 404 }); if (!await orderExists(BigInt(idOrder))) throw createError({ statusCode: 404 });
if (!checkIsImportedProduct(body, false)) throw createError({ message: "Invalid body", statusCode: 400 }); if (!checkIsImportedProduct(body, false, false)) throw createError({ message: "Invalid body", statusCode: 400 });
await database.query( const rvalue = await database.importedProduct.create({
["INSERT INTO", data: {
"`imported_products`", id: idImportedProduct,
"VALUES", link: body.link,
"(?, ?, ?, ?, ?, ?)", name: body.name,
].join(" "), orderId: BigInt(idOrder),
[idImportedProducts, idOrder, body.name, body.link, body.price_imported, body.price], price: body.price,
); price_imported: body.price_imported,
},
});
setResponseStatus(e, 201); setResponseStatus(e, 201);
return getImportedProducts(idOrder);
return prismaToWeb(rvalue);
}); });

View file

@ -1,12 +1,31 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { orderExists, getWork } from "../[id].get"; import { orderExists } from "../[id].get";
import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const id = e.context.params?.id as string; const orderId = e.context.params?.id as string;
if (!orderExists(id)) throw createError({ statusCode: 404 }); if (!await orderExists(BigInt(orderId))) throw createError({ statusCode: 404 });
const work = await getWork(id); const data = await database.work.findMany({
return work; where: {
orderId: BigInt(orderId),
},
select: {
id: true,
fulfilled: true,
notes: true,
offer: true,
orderId: true,
price: true,
},
});
if (!data) throw createError({ statusCode: 404 });
return prismaToWeb(data);
}); });

View file

@ -1,28 +1,34 @@
/* global defineEventHandler, readBody, createError, setResponseStatus */ import { defineEventHandler, readBody, setResponseStatus } from "h3";
import { Prisma } from "@prisma/client";
import { checkIsWork } from "../../orders.post"; import { checkIsWork } from "../../orders.post";
import { getWork, orderExists } from "../[id].get"; import { orderExists } from "../[id].get";
import Snowflake from "~/utils/snowflake"; import Snowflake from "~/utils/snowflake";
import { database } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const body = await readBody(e); const body = await readBody(e);
const idOrder = e.context.params?.id as string; const idOrder = e.context.params?.id as string;
const idWork = new Snowflake().toString(); const idWork = new Snowflake().state;
if (!orderExists(idOrder)) throw createError({ statusCode: 404 }); if (!orderExists(BigInt(idOrder))) throw createError({ statusCode: 404 });
if (!checkIsWork(body, false)) throw createError({ message: "Invalid body", statusCode: 400 }); if (!checkIsWork(body, false, false)) throw createError({ message: "Invalid body", statusCode: 400 });
await database.query( const rvalue = await database.work.create({
["INSERT INTO", data: {
"`work`", id: BigInt(idWork),
"VALUES", fulfilled: body.fulfilled,
"(?, ?, ?, ?, ?, ?)", notes: body.notes,
].join(" "), offerId: BigInt(body.offerId),
[idWork, idOrder, body.offer, body.price, body.notes, body.is_fulfilled], orderId: BigInt(body.orderId),
); price: new Prisma.Decimal(body.price),
},
});
setResponseStatus(e, 201); setResponseStatus(e, 201);
return getWork(idWork); return prismaToWeb(rvalue);
}); });

View file

@ -1,20 +1,24 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { ResultSetHeader } from "mysql2";
import { orderExists } from "../../[id].get";
import { database } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import { createError } from "#imports";
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const idOrder = e.context.params?.id as string; const idOrder = e.context.params?.id as string;
const idWork = e.context.params?.idWork as string; const idWork = e.context.params?.idWork as string;
if (!orderExists(idOrder)) throw createError({ statusCode: 404 }); try {
await database.work.delete({
where: {
id: BigInt(idWork),
orderId: BigInt(idOrder),
},
});
} catch (e) {
// FIXME: should be 500 on errors other than "RecordNotFound"
throw createError({ statusCode: 404 });
}
const [response] = await database.query(
"DELETE FROM `work` WHERE `id` = ?",
[idWork],
) as unknown as [ResultSetHeader];
if (response.affectedRows === 0) throw createError({ statusCode: 404 });
return null; return null;
}); });

View file

@ -1,11 +1,32 @@
/* global defineEventHandler, createError */ import { defineEventHandler } from "h3";
import { orderExists, getWork } from "../../[id].get"; import { orderExists } from "../../[id].get";
import { database } from "~/server/utils/database";
import { prismaToWeb } from "~/server/utils/prismaToWeb";
export default defineEventHandler((e) => { import { createError } from "#imports";
export default defineEventHandler(async (e) => {
const idOrder = e.context.params?.id as string; const idOrder = e.context.params?.id as string;
const idWork = e.context.params?.idWork as string; const idWork = e.context.params?.idWork as string;
if (!orderExists(idOrder)) throw createError({ statusCode: 404 }); if (!await orderExists(BigInt(idOrder))) throw createError({ statusCode: 404 });
return getWork(idWork); const data = await database.work.findUnique({
where: {
orderId: BigInt(idOrder),
id: BigInt(idWork),
},
select: {
id: true,
fulfilled: true,
notes: true,
offer: true,
orderId: true,
price: true,
},
});
if (!data) throw createError({ statusCode: 404 });
return prismaToWeb(data);
}); });

View file

@ -0,0 +1,9 @@
import { defineEventHandler } from "h3";
import { database } from "~/server/utils/database";
export default defineEventHandler(async (e) => {
return {
count: await database.order.count({}),
};
});

View file

@ -1,14 +1,8 @@
/* global defineEventHandler, getCookie */ import { defineEventHandler } from "h3";
import { database, data } from "~/server/utils/database"; import getRequestingUser from "~/server/utils/getRequestingUser";
import { user } from "~/utils/types/database"; import { prismaToWeb } from "~/server/utils/prismaToWeb";
export default defineEventHandler(async (e) => { export default defineEventHandler((e) => {
const token = getCookie(e, "token"); return getRequestingUser(e).then(prismaToWeb);
const [[userData]] = await database.query(
"SELECT CONVERT(`users`.`id`, CHAR(32)) as `id`, `users`.`username` as `username`, `users`.`email` as `email`, `users`.`display_name` as `display_name` FROM `sessions` LEFT JOIN `users` ON `sessions`.`user` = `users`.`id` WHERE `sessions`.`id` = ?",
[token],
) as unknown as data<user>;
return userData;
}); });

View file

@ -1,6 +1,10 @@
/* global defineEventHandler, createError, getCookie */ import { defineEventHandler, getCookie } from "h3";
import SessionToken from "../utils/SessionToken";
import { database, data } from "~/server/utils/database"; import { database } from "~/server/utils/database";
import getRequestingUser from "~/server/utils/getRequestingUser";
import { createError } from "#imports";
const endpointsWithoutAuth: string[] = [ const endpointsWithoutAuth: string[] = [
"/dbtest", "/dbtest",
@ -8,6 +12,7 @@ const endpointsWithoutAuth: string[] = [
"/hi", "/hi",
"/login", "/login",
"/logout", "/logout",
"/firstRun",
]; ];
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
@ -31,10 +36,18 @@ export default defineEventHandler(async (e) => {
*/ */
export async function isAuthorised(token: string | undefined): Promise<boolean> { export async function isAuthorised(token: string | undefined): Promise<boolean> {
if (!token) return false; if (!token) return false;
const [[session]] = await database.query( try {
"SELECT EXISTS(SELECT `id` FROM `sessions` WHERE `id` = ? AND `expiry_date` >= NOW()) as `logged_in`", await database.session.findUniqueOrThrow({
[token], where: {
) as unknown as data<{logged_in: number}>; ...SessionToken.fromString(token).toPrisma(),
expiry_date: {
gte: new Date(),
},
},
});
return session.logged_in === 1; return true;
} catch (e) {
return false;
}
} }

View file

@ -0,0 +1,49 @@
import crypto from "node:crypto";
import { type Session } from "@prisma/client";
import Snowflake from "~/utils/snowflake";
/** Represents a Session token, without expiry data. */
export default class SessionToken {
userId: bigint;
sessionId: bigint;
sessionToken: Buffer;
constructor(userId: bigint, sessionId?: bigint, sessionToken?: Buffer) {
this.userId = userId;
this.sessionId = sessionId ?? new Snowflake().state;
this.sessionToken = sessionToken ?? crypto.randomBytes(64);
}
/** Creates SessionToken from a string.
* @param string The strinct to create from.
* @returns The SessionToken object.
*/
static fromString(string: string): SessionToken {
const parameters = string.split(".");
return new SessionToken(
Buffer.from(parameters[0], "base64").readBigUInt64LE(),
Buffer.from(parameters[1], "base64").readBigUInt64LE(),
Buffer.from(parameters[2], "base64"),
);
}
toString(): string {
const stringUserId = Buffer.copyBytesFrom(new BigUint64Array([this.userId])).toString("base64");
const stringSessionId = Buffer.copyBytesFrom(new BigUint64Array([this.sessionId])).toString("base64");
const stringSessionToken = this.sessionToken.toString("base64");
return `${stringUserId}.${stringSessionId}.${stringSessionToken}`;
}
/** Returns this SessionToken as Prisma object.
* For use in where parameter.
* @returns this as prisma object.
*/
toPrisma(): Omit<Session, "expiry_date"> {
return {
id: this.sessionId,
userId: this.userId,
sessionToken: this.sessionToken,
};
}
}

View file

@ -0,0 +1,108 @@
// BAA stands for Before Around After
import { getQuery, type H3Event } from "h3";
import { type QueryObject } from "ufo";
import { isString } from "./isString";
import { createError } from "#imports";
type queryType<none extends boolean = boolean> = none extends false ? {
type: "before" | "after" | "around",
id: bigint
} : {
type: null
};
export type pageData<none extends boolean = boolean> = queryType<none> & { count: number }
/**
* Gets queryType for a given query with a value
* @param query the query to parse
* @throws if query malformed (multiple before/after/around)
*/
function getLocationParameterType(query: QueryObject): queryType {
const before = query.before;
const after = query.after;
const around = query.around;
let setLocationParametersCount = 0;
let rvalue: queryType = { type: null };
if (isString(before)) {
setLocationParametersCount++;
rvalue = { type: "before", id: BigInt(before) };
}
if (isString(after)) {
setLocationParametersCount++;
rvalue = { type: "after", id: BigInt(after) };
}
if (isString(around)) {
setLocationParametersCount++;
rvalue = { type: "around", id: BigInt(around) };
}
if (setLocationParametersCount > 1) {
throw createError({
statusCode: 400,
message: "multiple location parameters not allowed",
});
}
return rvalue;
}
/** Gets the count parameter from the query object.
* @param query the query to check.
* @param defaultCount the default count if the query doesn't have count parameter. (default 50)
* @param countLimit the maximum count of the parameter before throwing. (default 200)
* @returns the value of count parameter.
* @throws if the parameter in query exceeds provided countLimit.
*/
function getRequestedCount(
query: QueryObject,
defaultCount = 50,
countLimit = 200,
) {
let count = defaultCount;
if (query.limit) count = Number(query.limit);
if (count > countLimit) {
throw createError({
statusCode: 400,
message: `Cannot retrieve more than ${countLimit} records`,
});
}
if (count <= 0) {
throw createError({
statusCode: 400,
message: "Tried to retireve 0 or less records",
});
}
return count;
}
/** Gets the baa page parameters from the H3event,
* @param e the H3event to fetch parameters.
* @param defaultCount the default count to use if there is no count parameter. (default 50)
* @param countLimit the maximum value of the count parameter before throwing an error. (default 200)
* @returns the page data found in the query.
* @throws if event has a count parameter in the query that exceed provided countLimit.
*/
export default function getPaginatedParameters(
e: H3Event,
defaultCount = 50,
countLimit = 200,
): pageData {
const query = getQuery(e);
const queryParameters = getLocationParameterType(query);
const queryCount = getRequestedCount(query, defaultCount, countLimit);
return {
...queryParameters,
count: queryCount,
};
}

View file

@ -1,261 +0,0 @@
/* global defineEventHandler, getQuery, createError, readBody, setResponseStatus */
import { QueryObject } from "ufo";
import { H3Event } from "h3";
import { ResultSetHeader } from "mysql2/promise";
import { data, database } from "./database";
import { isString } from "./isString";
import Snowflake from "~/utils/snowflake";
import { client } from "~/utils/types/database";
type queryType = {
type: "before" | "after" | "around",
id: string
} | {
type: null
};
/**
* Before, around, after pagination wrapper
*/
export default class BaaPagination<T extends {[k: string]: any}, keyType extends string = "id"> {
readonly table: string;
readonly key: keyType;
readonly select: string;
readonly groupBy: string;
private get sqlGroupBy() {
return this.groupBy !== "" ? `GROUP BY ${this.groupBy}` : "";
}
/**
* Gets queryType for a given query with a value
* @param query the query to parse
* @throws if query malformed (multiple before/after/around)
*/
static getLocationParameterType(query: QueryObject): queryType {
const before = query.before;
const after = query.after;
const around = query.around;
let setLocationParametersCount = 0;
let rvalue: queryType = { type: null };
if (isString(before)) {
setLocationParametersCount++;
rvalue = { type: "before", id: before };
}
if (isString(after)) {
setLocationParametersCount++;
rvalue = { type: "after", id: after };
}
if (isString(around)) {
setLocationParametersCount++;
rvalue = { type: "around", id: around };
}
if (setLocationParametersCount > 1) {
throw createError({
statusCode: 400,
message: "multiple location parameters not allowed",
});
}
return rvalue;
}
async getPagedResults(
queryType: queryType,
limit = 50,
where = "",
bind: Array<any> = [],
) {
const sqlwhere = where !== "" ? `AND (${where})` : "";
switch (queryType.type) {
case "before": {
const [data] = await database.query(
`SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` < ? ${sqlwhere} ORDER BY \`${this.key}\` DESC ${this.sqlGroupBy} LIMIT ?`,
[queryType.id, ...bind, limit],
) as unknown as data<T>;
return data;
}
case "after": {
const [data] = await database.query(
`SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` > ? ${sqlwhere} ORDER BY \`${this.key}\` DESC ${this.sqlGroupBy} LIMIT ?`,
[queryType.id, ...bind, limit],
) as unknown as data<T>;
return data;
}
case "around": {
const [data] = await database.query(
` SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM (\n` +
`(SELECT * FROM \`${this.table}\` WHERE \`${this.key}\` >= ? ${sqlwhere} ORDER BY \`${this.key}\` ${this.sqlGroupBy} ASC LIMIT ?)\n` +
"UNION ALL\n" +
`(SELECT ${this.select} FROM \`${this.table}\` WHERE \`${this.key}\` < ? ${sqlwhere} ORDER BY \`${this.key}\` DESC ${this.sqlGroupBy} LIMIT ?)\n` +
`) as \`x\` ORDER BY \`${this.key}\` DESC`,
[queryType.id, ...bind, Math.ceil(limit / 2), queryType.id, ...bind, Math.floor(limit / 2)],
) as unknown as data<T>;
return data;
}
case null: {
const [data] = await database.query(
`SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE TRUE ${sqlwhere} ORDER BY \`${this.key}\` DESC ${this.sqlGroupBy} LIMIT ?`,
[...bind, limit],
) as unknown as data<T>;
return data;
}
default:
throw createError("Not implemented");
}
}
RESTget(
e: H3Event,
defaultLimit = 50,
limitLimit = 200,
where = "",
bind: Array<any> = [],
) {
const query = getQuery(e);
let limit = defaultLimit;
if (query.limit) limit = Number(query.limit);
if (limit > limitLimit) {
throw createError({
statusCode: 400,
message: `Cannot retrieve more than ${limitLimit} records`,
});
}
if (limit <= 0) {
throw createError({
statusCode: 400,
message: "Tried to retireve 0 or less records",
});
}
const queryData = BaaPagination.getLocationParameterType(query);
return this.getPagedResults(queryData, limit, where, bind);
}
async RESTpost<K extends keyof Omit<T, keyType>>(
e: H3Event,
fields: Array<K>,
valueChecker: (obj: unknown) => obj is {[P in K]: T[P]},
) {
const body = await readBody(e);
const id = new Snowflake().toString();
if (!valueChecker(body)) throw createError({ message: "Invalid body", statusCode: 400 });
const arrayToInsert: Array<any> = [id];
arrayToInsert.push(...fields.map(field => body[field]));
await database.query(
`INSERT INTO \`${this.table}\` ` +
`(\`${this.key}\`,\`${fields.join("`, `")}\`) ` +
"VALUES (" +
"?, ".repeat(fields.length) +
"?)",
arrayToInsert,
);
setResponseStatus(e, 201);
// FIXME: data may be turncated in the database
// either throw an error when data is too large or
// reply with turncated data
return { id, ...body };
}
async RESTgetRecord(e: H3Event) {
const key = e.context.params?.[this.key];
const [data] = await database.query(
`SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
[key],
) as data<T>;
if (!data[0]) {
throw createError({
statusCode: 404,
});
}
return data[0];
}
async RESTpatchRecord(
e: H3Event,
valueChecker: (obj: unknown) => obj is Partial<Omit<T, keyType>>,
) {
const body = await readBody(e);
const key = e.context.params?.[this.key];
if (!valueChecker(body)) throw createError({ message: "Invalid body", statusCode: 400 });
for (const [k, v] of Object.entries(body)) {
// FIXME: use single database.query method instead of looping through keys and values
const [res] = await database.query(
// I believe it is safe to put key (k) in the template
// because it is limited to 4 values here
`UPDATE \`${this.table}\` SET \`${k}\` = ? WHERE \`${this.key}\` = ?`,
[v, key],
) as unknown as [ResultSetHeader];
if (res.affectedRows !== 1) {
throw createError({
statusCode: 404,
});
}
}
const [data] = await database.query(
`SELECT ${this.select}, CONVERT(\`${this.key}\`, CHAR) AS \`${this.key}\` FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
[key],
) as data<T>;
return data[0];
}
async RESTdeleteRecord(e: H3Event) {
const key = e.context.params?.[this.key];
const [result] = await database.query(
`DELETE FROM \`${this.table}\` WHERE \`${this.key}\` = ?`,
[key],
) as unknown as [ResultSetHeader];
if (result.affectedRows === 0) throw createError({ statusCode: 404 });
return null;
}
async RESTrecordCount(
e :H3Event,
where = "",
bind: Array<any> = [],
) {
const sqlwhere = where !== "" ? `WHERE ${where}` : "";
const [[data]] = await database.query(
`SELECT COUNT(*) as \`count\` FROM \`${this.table}\` ${sqlwhere} ${this.sqlGroupBy}`,
bind,
) as data<{count: number}>;
if (!data) throw createError("Database returned no rows");
return data;
}
constructor(
table: string,
key: keyType,
select = "*",
groupBy = "",
) {
this.table = table;
this.key = key;
this.select = select;
this.groupBy = groupBy;
}
}

View file

@ -1,17 +1,102 @@
import mysql, { Connection } from "mysql2/promise"; import { PrismaClient, Prisma } from "@prisma/client";
import * as PrismaEngines from "@prisma/engines";
const connectionOptions: mysql.ConnectionOptions = { import { type pageData } from "./baaPageParsing";
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_SCHEMA,
decimalNumbers: true,
supportBigNumbers: true,
};
export const database = type model = PrismaClient[Uncapitalize<Prisma.ModelName>];
await mysql.createConnection(connectionOptions) as Connection & { new: () => Promise<Connection> };
database.new = () => { return mysql.createConnection(connectionOptions); };
export type data<T> = [T[], mysql.FieldPacket[]]; PrismaEngines.ensureBinariesExist();
function getBeforeParameters<T, A>(
pageData: pageData<false>,
fetchArgs: Prisma.Args<T, "findMany">,
) {
const _fetchArgs = Object.assign({}, fetchArgs);
return Object.assign(_fetchArgs, {
take: pageData.count,
orderBy: [
{ id: "desc" },
],
where: {
AND: [
{
id: {
_lt: pageData.id,
},
},
fetchArgs.where,
],
},
});
}
function getAfterParameters<T>(
pageData: pageData<false>,
fetchArgs: Prisma.Args<T, "findMany">,
) {
const _fetchArgs = Object.assign({}, fetchArgs);
return Object.assign(_fetchArgs, {
take: pageData.count,
orderBy: [
{ id: "desc" },
],
where: {
AND: [
{
id: {
_gt: pageData.id,
},
},
fetchArgs.where,
],
},
});
}
function getNullParameters<T>(
pageData: pageData<true>,
fetchArgs: Prisma.Args<T, "findMany">,
) {
const _fetchArgs = Object.assign({}, fetchArgs);
return Object.assign(_fetchArgs, {
take: pageData.count,
orderBy: [
{ id: "desc" },
],
});
}
export const database = new PrismaClient().$extends({
model: {
$allModels: {
findPaginated<T, A>(
this: T,
pageData: pageData,
fetchArgs: Prisma.Exact<A, Prisma.Args<T, "findMany">>,
): Promise<Prisma.Result<T, A, "findMany">> {
const context = Prisma.getExtensionContext(this) as any;
switch (pageData.type) {
case "before":
return context.findMany(getBeforeParameters(pageData, fetchArgs));
case "after":
return context.findMany(getAfterParameters(pageData, fetchArgs));
case "around":
return Promise.all([
context.findMany(getBeforeParameters({
type: "before",
id: pageData.id,
count: Math.ceil(pageData.count),
}, fetchArgs)),
context.findMany(getAfterParameters({
type: "after",
id: pageData.id,
count: Math.floor(pageData.count),
}, fetchArgs)),
]).then(rv => rv.flat()) as Promise<any>;
case null:
return context.findMany(getNullParameters(pageData, fetchArgs));
}
},
},
},
});

View file

@ -1,24 +1,34 @@
/* global getCookie, createError */ import { getCookie, H3Event } from "h3";
import { H3Event } from "h3";
import { database, data } from "./database"; import { database } from "./database";
import { user } from "~/utils/types/database"; import SessionToken from "./SessionToken";
import { createError } from "#imports";
export default async function getRequestingUser(e: H3Event) { export default async function getRequestingUser(e: H3Event) {
const cookie = getCookie(e, "token"); const cookie = getCookie(e, "token");
const [[user]] = await database.query( if (!cookie) throw createError("User not found");
["SELECT", const { user } = await database.session.findUnique({
"CONVERT(`users`.`id`, CHAR) as `id`,", where: {
"`users`.`username`,", ...SessionToken.fromString(cookie).toPrisma(),
"`users`.`email`,", expiry_date: {
"`users`.`display_name`", gte: new Date(),
"FROM", },
"`sessions`", },
"LEFT JOIN `users` ON `sessions`.`user` = `users`.`id`", select: {
"WHERE `sessions`.`id` = ?", user: {
].join(" "), select: {
[cookie], display_name: true,
) as data<user>; email: true,
id: true,
username: true,
},
},
},
}).then((e) => {
if (e === null) throw createError("User not found");
return e;
});
if (!user) throw createError("User not found"); if (!user) throw createError("User not found");
return user; return user;

View file

@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
type func = (...args: any[]) => any | Function;
export type replaceJsonUnparsableToString<T> =
T extends Array<infer E> ? Array<replaceJsonUnparsableToString<E>>
: {
[K in keyof T]:
T[K] extends null ? null
: T[K] extends func ? never
: T[K] extends Prisma.Decimal ? `${number}`
: T[K] extends Array<infer E> ? Array<replaceJsonUnparsableToString<E>>
: T[K] extends object ? replaceJsonUnparsableToString<T[K]>
: T[K] extends bigint ? `${bigint}`
: T[K]
};
type exactToInterface = (...args: any[]) => any extends Function ? true : false;
function arrayPrismaToWeb<T>(array: Array<T>) {
return array.reduce(
(pV, cV) => {
pV.push(prismaToWeb(cV));
return pV;
},
[] as Array<replaceJsonUnparsableToString<T>>,
);
}
export function prismaToWeb<T>(ivalue: T): replaceJsonUnparsableToString<T> {
const rvalue: any = ivalue instanceof Array ? [] : {};
for (const i in ivalue) {
const current = ivalue[i];
if (current === null) rvalue[i] = null;
else if (typeof current === 'function') continue;
else if (current instanceof Prisma.Decimal) rvalue[i] = current.toString();
else if (current instanceof Array) rvalue[i] = arrayPrismaToWeb(current);
else if (typeof current === 'object') rvalue[i] = prismaToWeb(current);
else if (typeof current === 'bigint') rvalue[i] = current.toString();
else rvalue[i] = current;
}
return rvalue;
}

View file

@ -1,4 +1,4 @@
/* global createError */ import { createError } from "#imports";
export function createValidationError(errors: Map<string, string>) { export function createValidationError(errors: Map<string, string>) {
let message = "Invalid parameters: "; let message = "Invalid parameters: ";

87
shell.nix Normal file
View file

@ -0,0 +1,87 @@
{ pkgs ? import <nixpkgs> {} }:
let
# Updating this package will force an update for nodePackages.prisma. The
# version of prisma-engines and nodePackages.prisma must be the same for them to
# function correctly.
prisma-version = "5.7.1";
prisma-src = pkgs.fetchFromGitHub {
owner = "prisma";
repo = "prisma-engines";
rev = prisma-version;
hash = "sha256-EOYbWUgoc/9uUtuocfWDh0elExzL0+wb4PsihgMbsWs=";
};
new-prisma-engines = pkgs.rustPlatform.buildRustPackage {
pname = "prisma-engines";
version = prisma-version;
src = builtins.storePath prisma-src;
# Use system openssl.
OPENSSL_NO_VENDOR = 1;
nativeBuildInputs = [ pkgs.pkg-config pkgs.git ];
buildInputs = [
pkgs.openssl
pkgs.protobuf
];
cargoLock = {
lockFile = "${prisma-src}/Cargo.lock";
outputHashes = {
"cuid-1.3.2" = "sha256-ZihFrLerEIOdbJggaBbByRbC1sZRvF4M0LN2albB7vA=";
"barrel-0.6.6-alpha.0" = "sha256-USh0lQ1z+3Spgc69bRFySUzhuY79qprLlEExTmYWFN8=";
"graphql-parser-0.3.0" = "sha256-0ZAsj2mW6fCLhwTETucjbu4rPNzfbNiHu2wVTBlTNe4=";
"mysql_async-0.31.3" = "sha256-QIO9s0Upc0/1W7ux1RNJNGKqzO4gB4gMV3NoakAbxkQ=";
"postgres-native-tls-0.5.0" = "sha256-UYPsxhCkXXWk8yPbqjNS0illwjS5mVm3Z/jFwpVwqfw=";
};
};
preBuild = ''
export OPENSSL_DIR=${pkgs.lib.getDev pkgs.openssl}
export OPENSSL_LIB_DIR=${pkgs.lib.getLib pkgs.openssl}/lib
export PROTOC=${pkgs.protobuf}/bin/protoc
export PROTOC_INCLUDE="${pkgs.protobuf}/include";
export SQLITE_MAX_VARIABLE_NUMBER=250000
export SQLITE_MAX_EXPR_DEPTH=10000
'';
cargoBuildFlags = [
"-p" "query-engine"
"-p" "query-engine-node-api"
"-p" "schema-engine-cli"
"-p" "prisma-fmt"
];
postInstall = ''
mv $out/lib/libquery_engine${pkgs.stdenv.hostPlatform.extensions.sharedLibrary} $out/lib/libquery_engine.node
'';
# Tests are long to compile
doCheck = false;
# meta = with lib; {
# description = "A collection of engines that power the core stack for Prisma";
# homepage = "https://www.prisma.io/";
# license = licenses.asl20;
# platforms = platforms.unix;
# maintainers = with maintainers; [ pimeys tomhoule ivan aqrln ];
# };
};
in
pkgs.mkShell {
nativeBuildInputs = [
new-prisma-engines
pkgs.nodejs_18
pkgs.openssl
];
shellHook = ''
export PRISMA_SCHEMA_ENGINE_BINARY="${new-prisma-engines}/bin/schema-engine"
export PRISMA_QUERY_ENGINE_BINARY="${new-prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${new-prisma-engines}/lib/libquery_engine.node"
export PRISMA_FMT_BINARY="${new-prisma-engines}/bin/prisma-fmt"
'';
}

View file

@ -1,4 +1,4 @@
import { CookieSerializeOptions } from "cookie-es"; import { type CookieSerializeOptions } from "cookie-es";
export const cookieSettings: CookieSerializeOptions = { export const cookieSettings: CookieSerializeOptions = {
sameSite: "lax", sameSite: "lax",

View file

@ -1,99 +0,0 @@
export interface client {
id: string;
name: string | null;
address: string | null;
phone: string | null;
email: string | null;
}
export interface user {
id: string;
username: string;
email: string;
display_name?: string;
}
export interface session {
id: string;
user: string;
expiry_date: string;
}
export interface imported_product {
id: string;
// order: string,
name?: string;
link: string;
price_imported: string;
price: string;
}
export interface offer {
id: string;
name: string;
description?: string;
recommended_price?: string;
}
export interface work {
id: string;
// order: string,
offer: string | offer;
price: string;
notes: string;
is_fulfilled: boolean;
}
export interface order {
imported_products: Array<{
id: string;
name: string | null;
link: string;
price: string;
price_imported: string;
}>;
work: {
id: string;
offer: offer;
price: number;
notes: string | null;
is_fulfilled: 0 | 1;
}[];
id: string;
client: string;
user: string;
is_draft: 0 | 1;
value: number;
}
export interface orderSummary {
id: string;
client: string;
user: string;
is_draft: 0 | 1;
value: string;
imported_products_count: number;
work_count: number;
}
export interface work_template {
id: string;
// order_template: string,
offer: string | offer;
price: string;
notes?: string;
}
export interface order_template {
id: string;
name: string;
description?: string;
}
// 1 is true, 0 is false
export type Dboolean =
| boolean
| 0 // false
| 1; // true
export type Dnumber = number | `${number}`;