chore: germantax customer project
This commit is contained in:
17
packages/components/package.json
Normal file
17
packages/components/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@germantax/components",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/hooks": "^9.2.1",
|
||||
"@pikku/mantine": "^0.12.6",
|
||||
"@pikku/react": "^0.12.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
}
|
||||
2
packages/components/src/index.ts
Normal file
2
packages/components/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './page-header.js'
|
||||
export { StatusBadge } from './status-badge.js'
|
||||
27
packages/components/src/page-header.tsx
Normal file
27
packages/components/src/page-header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Title, Text, Group, Stack } from '@pikku/mantine/core'
|
||||
import { type ReactNode } from 'react'
|
||||
import type { I18nNode } from '@pikku/react'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: I18nNode
|
||||
description?: I18nNode
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>{title}</Title>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
33
packages/components/src/status-badge.tsx
Normal file
33
packages/components/src/status-badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Badge } from '@pikku/mantine/core'
|
||||
import { asI18n } from '@pikku/react'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
pending: 'yellow',
|
||||
in_review: 'orange',
|
||||
under_review: 'orange',
|
||||
approved: 'blue',
|
||||
active: 'green',
|
||||
open: 'green',
|
||||
filed: 'green',
|
||||
completed: 'gray',
|
||||
archived: 'gray',
|
||||
closed: 'gray',
|
||||
rejected: 'red',
|
||||
overdue: 'red',
|
||||
cancelled: 'red',
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const color = STATUS_COLORS[status] ?? 'gray'
|
||||
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm">
|
||||
{asI18n(formatStatus(status))}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
19
packages/functions-sdk/package.json
Normal file
19
packages/functions-sdk/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@germantax/functions-sdk",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./pikku/api.gen": "./src/pikku/api.gen.ts",
|
||||
"./pikku/pikku-fetch.gen": "./src/pikku/pikku-fetch.gen.ts",
|
||||
"./pikku/pikku-rpc.gen": "./src/pikku/pikku-rpc.gen.ts",
|
||||
"./pikku/rpc-map.gen": "./src/pikku/rpc-map.gen.d.ts",
|
||||
"./pikku/http-map.gen": "./src/pikku/http-map.gen.d.ts",
|
||||
"./pikku/*": "./src/pikku/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikku/fetch": "^0.12.6",
|
||||
"@pikku/react": "^0.12.5",
|
||||
"@tanstack/react-query": "^5"
|
||||
}
|
||||
}
|
||||
70
packages/functions/db/schema.d.ts
vendored
Normal file
70
packages/functions/db/schema.d.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
// Generated by @pikku/cli — do not edit by hand.
|
||||
// Run `pikku db migrate` to refresh.
|
||||
|
||||
import type { ColumnType } from 'kysely'
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>
|
||||
|
||||
export interface AuditLog {
|
||||
auditId: Generated<string>
|
||||
tableName: string
|
||||
recordId: string
|
||||
action: "INSERT" | "UPDATE" | "DELETE"
|
||||
userId: string | null
|
||||
changedFields: string | null
|
||||
createdAt: Generated<Date>
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
companyId: Generated<string>
|
||||
name: string
|
||||
registryNo: string | null
|
||||
addressLine1: string | null
|
||||
addressLine2: string | null
|
||||
postcode: string | null
|
||||
town: string | null
|
||||
country: string | null
|
||||
totalShares: Generated<number>
|
||||
fiscalYearEnd: string | null
|
||||
createdAt: Generated<Date>
|
||||
}
|
||||
|
||||
export interface CompanyMember {
|
||||
memberId: Generated<string>
|
||||
companyId: string
|
||||
userId: string
|
||||
role: "managing_director" | "shareholder" | "angel"
|
||||
shares: number | null
|
||||
createdAt: Generated<Date>
|
||||
}
|
||||
|
||||
export interface Resolution {
|
||||
resolutionId: Generated<string>
|
||||
companyId: string
|
||||
templateKey: string
|
||||
title: string
|
||||
status: Generated<"draft" | "published" | "archived">
|
||||
fieldValues: Generated<string>
|
||||
pdfKey: string | null
|
||||
createdBy: string
|
||||
createdAt: Generated<Date>
|
||||
publishedAt: Date | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
userId: Generated<string>
|
||||
email: string
|
||||
displayName: string
|
||||
passwordHash: string | null
|
||||
createdAt: Generated<Date>
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
auditLog: AuditLog
|
||||
company: Company
|
||||
companyMember: CompanyMember
|
||||
resolution: Resolution
|
||||
user: User
|
||||
}
|
||||
26
packages/functions/package.json
Normal file
26
packages/functions/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@germantax/functions",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#pikku": "./.pikku/pikku-types.gen.ts",
|
||||
"#pikku/*.gen.js": "./.pikku/*.gen.ts",
|
||||
"#pikku/*": "./.pikku/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241218.0",
|
||||
"@pikku/better-auth": "^0.12.16",
|
||||
"@pikku/browser": "^0.12.43",
|
||||
"@pikku/cloudflare": "^0.12.13",
|
||||
"@pikku/core": "^0.12.57",
|
||||
"@pikku/kysely": "^0.13.0",
|
||||
"@pikku/kysely-sqlite": "^0.12.8",
|
||||
"@pikku/schedule": "^0.12.3",
|
||||
"@pikku/schema-cfworker": "^0.12.4",
|
||||
"better-auth": "^1.6.19",
|
||||
"kysely": "^0.29.0",
|
||||
"puppeteer-core": "22.13.1",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
23
packages/functions/pikku.config.json
Normal file
23
packages/functions/pikku.config.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/pikkujs/pikku/refs/heads/main/packages/cli/cli.schema.json",
|
||||
"rootDir": ".",
|
||||
"tsconfig": "../../tsconfig.json",
|
||||
"srcDirectories": ["src"],
|
||||
"outDir": ".pikku",
|
||||
"clientFiles": {
|
||||
"rpcMapDeclarationFile": "../sdk/src/pikku/rpc-map.gen.d.ts",
|
||||
"httpMapDeclarationFile": "../sdk/src/pikku/http-map.gen.d.ts",
|
||||
"fetchFile": "../sdk/src/pikku/pikku-fetch.gen.ts",
|
||||
"rpcWiringsFile": "../sdk/src/pikku/pikku-rpc.gen.ts",
|
||||
"reactQueryFile": "../sdk/src/pikku/api.gen.ts"
|
||||
},
|
||||
"packageMappings": {
|
||||
".": "@germantax/functions"
|
||||
},
|
||||
"schema": {
|
||||
"supportsImportAttributes": true
|
||||
},
|
||||
"scaffold": {
|
||||
"rpc": "auth"
|
||||
}
|
||||
}
|
||||
29
packages/functions/src/application-types.d.ts
vendored
Normal file
29
packages/functions/src/application-types.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
CoreServices,
|
||||
CoreSingletonServices,
|
||||
CoreConfig,
|
||||
CoreUserSession,
|
||||
ContentService,
|
||||
} from "@pikku/core"
|
||||
import type { Kysely } from "kysely"
|
||||
import type { BrowserService } from "@pikku/browser"
|
||||
import type { DB } from "./types/db.types.js"
|
||||
import type { AuditService } from "./services/audit.service.js"
|
||||
|
||||
export interface UserSession extends CoreUserSession {
|
||||
userId: string
|
||||
email: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export interface Config extends CoreConfig {}
|
||||
|
||||
export interface SingletonServices extends CoreSingletonServices<Config> {
|
||||
kysely: Kysely<DB>
|
||||
content: ContentService
|
||||
browser: BrowserService
|
||||
}
|
||||
|
||||
export interface Services extends CoreServices<SingletonServices> {
|
||||
audit: AuditService
|
||||
}
|
||||
11
packages/functions/src/config.ts
Normal file
11
packages/functions/src/config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { pikkuConfig } from "../.pikku/pikku-types.gen.js"
|
||||
|
||||
export const createConfig = pikkuConfig(async () => ({
|
||||
port: 4003,
|
||||
dev: {
|
||||
db: process.env.PIKKU_DEV_DB_FILE
|
||||
? { file: process.env.PIKKU_DEV_DB_FILE }
|
||||
: true,
|
||||
content: true,
|
||||
},
|
||||
} as any))
|
||||
19
packages/functions/src/functions/auth/get-me.function.ts
Normal file
19
packages/functions/src/functions/auth/get-me.function.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
|
||||
export const GetMeOutput = z.object({
|
||||
userId: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
})
|
||||
|
||||
export const getMe = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Return the current authenticated user session.',
|
||||
output: GetMeOutput,
|
||||
func: async (_services, _data, { session }) => ({
|
||||
userId: session!.userId,
|
||||
email: session!.email,
|
||||
displayName: session!.displayName,
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
|
||||
export const CreateCompanyInput = z.object({
|
||||
name: z.string().min(2),
|
||||
registryNo: z.string().optional(),
|
||||
addressLine1: z.string().optional(),
|
||||
addressLine2: z.string().optional(),
|
||||
postcode: z.string().optional(),
|
||||
town: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
totalShares: z.number().int().nonnegative(),
|
||||
fiscalYearEnd: z.string().optional(),
|
||||
})
|
||||
|
||||
export const CreateCompanyOutput = z.object({ companyId: z.string() })
|
||||
|
||||
export const createCompany = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Create a new GmbH and add the current user as managing_director.',
|
||||
input: CreateCompanyInput,
|
||||
output: CreateCompanyOutput,
|
||||
func: async ({ kysely, audit }, data, { session }) => {
|
||||
const userId = session!.userId
|
||||
const company = await kysely
|
||||
.insertInto('company')
|
||||
.values({
|
||||
name: data.name,
|
||||
registryNo: data.registryNo ?? null,
|
||||
addressLine1: data.addressLine1 ?? null,
|
||||
addressLine2: data.addressLine2 ?? null,
|
||||
postcode: data.postcode ?? null,
|
||||
town: data.town ?? null,
|
||||
country: data.country ?? null,
|
||||
totalShares: data.totalShares,
|
||||
fiscalYearEnd: data.fiscalYearEnd ?? null,
|
||||
})
|
||||
.returning(['companyId'])
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
await kysely
|
||||
.insertInto('companyMember')
|
||||
.values({
|
||||
companyId: company.companyId,
|
||||
userId,
|
||||
role: 'managing_director',
|
||||
shares: null,
|
||||
})
|
||||
.execute()
|
||||
|
||||
await audit.onInsert('company', company.companyId, { ...data })
|
||||
return { companyId: company.companyId }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const GetCompanyInput = z.object({ companyId: z.string() })
|
||||
export const GetCompanyOutput = z.object({
|
||||
companyId: z.string(),
|
||||
name: z.string(),
|
||||
registryNo: z.string().nullable(),
|
||||
addressLine1: z.string().nullable(),
|
||||
addressLine2: z.string().nullable(),
|
||||
postcode: z.string().nullable(),
|
||||
town: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
totalShares: z.number().int().nonnegative(),
|
||||
assignedShares: z.number().int().nonnegative(),
|
||||
fiscalYearEnd: z.string().nullable(),
|
||||
myRole: z.enum(['managing_director', 'shareholder', 'angel']),
|
||||
})
|
||||
|
||||
export const getCompany = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Fetch a company the current user is a member of.',
|
||||
input: GetCompanyInput,
|
||||
output: GetCompanyOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely }, { companyId }, { session }) => {
|
||||
const role = (
|
||||
await kysely
|
||||
.selectFrom('companyMember')
|
||||
.select(['role'])
|
||||
.where('companyId', '=', companyId)
|
||||
.where('userId', '=', session!.userId)
|
||||
.executeTakeFirstOrThrow()
|
||||
).role as 'managing_director' | 'shareholder' | 'angel'
|
||||
const c = await kysely
|
||||
.selectFrom('company')
|
||||
.selectAll()
|
||||
.where('companyId', '=', companyId)
|
||||
.executeTakeFirstOrThrow()
|
||||
const assigned = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.select((eb) => eb.fn.sum<number>('shares').as('total'))
|
||||
.where('companyId', '=', companyId)
|
||||
.executeTakeFirst()
|
||||
return {
|
||||
companyId: c.companyId,
|
||||
name: c.name,
|
||||
registryNo: c.registryNo,
|
||||
addressLine1: c.addressLine1,
|
||||
addressLine2: c.addressLine2,
|
||||
postcode: c.postcode,
|
||||
town: c.town,
|
||||
country: c.country,
|
||||
totalShares: c.totalShares,
|
||||
assignedShares: Number(assigned?.total ?? 0),
|
||||
fiscalYearEnd: c.fiscalYearEnd,
|
||||
myRole: role,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
|
||||
export const ListMyCompaniesOutput = z.array(
|
||||
z.object({
|
||||
companyId: z.string(),
|
||||
name: z.string(),
|
||||
registryNo: z.string().nullable(),
|
||||
town: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
totalShares: z.number().int().nonnegative(),
|
||||
assignedShares: z.number().int().nonnegative(),
|
||||
fiscalYearEnd: z.string().nullable(),
|
||||
role: z.enum(['managing_director', 'shareholder', 'angel']),
|
||||
}),
|
||||
)
|
||||
|
||||
export const listMyCompanies = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'List companies where the current user is a member.',
|
||||
output: ListMyCompaniesOutput,
|
||||
func: async ({ kysely }, _data, { session }) => {
|
||||
const rows = await kysely
|
||||
.selectFrom('companyMember as cm')
|
||||
.innerJoin('company as c', 'c.companyId', 'cm.companyId')
|
||||
.leftJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('companyMember')
|
||||
.select((b) => [
|
||||
'companyId',
|
||||
b.fn.sum<number>('shares').as('assignedShares'),
|
||||
])
|
||||
.groupBy('companyId')
|
||||
.as('s'),
|
||||
(join) => join.onRef('s.companyId', '=', 'c.companyId'),
|
||||
)
|
||||
.select([
|
||||
'c.companyId as companyId',
|
||||
'c.name as name',
|
||||
'c.registryNo as registryNo',
|
||||
'c.town as town',
|
||||
'c.country as country',
|
||||
'c.totalShares as totalShares',
|
||||
's.assignedShares as assignedShares',
|
||||
'c.fiscalYearEnd as fiscalYearEnd',
|
||||
'cm.role as role',
|
||||
])
|
||||
.where('cm.userId', '=', session!.userId)
|
||||
.orderBy('c.createdAt', 'desc')
|
||||
.execute()
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
assignedShares: Number(r.assignedShares ?? 0),
|
||||
})) as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isManagingDirector } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const AddMemberInput = z.object({
|
||||
companyId: z.string(),
|
||||
email: z.string().email(),
|
||||
displayName: z.string().min(2),
|
||||
role: z.enum(['managing_director', 'shareholder', 'angel']),
|
||||
shares: z.number().int().nonnegative().nullable().optional(),
|
||||
})
|
||||
|
||||
export const AddMemberOutput = z.object({ memberId: z.string() })
|
||||
|
||||
export const addMember = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Add a member to the company. Caller must be managing_director.',
|
||||
input: AddMemberInput,
|
||||
output: AddMemberOutput,
|
||||
permissions: { md: [isManagingDirector] },
|
||||
func: async ({ kysely, audit }, data) => {
|
||||
const incoming = data.shares ?? 0
|
||||
if (incoming > 0) {
|
||||
const company = await kysely
|
||||
.selectFrom('company')
|
||||
.select(['totalShares'])
|
||||
.where('companyId', '=', data.companyId)
|
||||
.executeTakeFirstOrThrow()
|
||||
const assignedRow = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.select((eb) => eb.fn.sum<number>('shares').as('total'))
|
||||
.where('companyId', '=', data.companyId)
|
||||
.executeTakeFirst()
|
||||
const assigned = Number(assignedRow?.total ?? 0)
|
||||
if (assigned + incoming > company.totalShares) {
|
||||
const remaining = Math.max(0, company.totalShares - assigned)
|
||||
throw new Error(
|
||||
`Shares exceed company total. ${remaining} of ${company.totalShares} remaining.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const email = data.email.trim().toLowerCase()
|
||||
let user = await kysely
|
||||
.selectFrom('user')
|
||||
.selectAll()
|
||||
.where('email', '=', email)
|
||||
.executeTakeFirst()
|
||||
if (!user) {
|
||||
const newId = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
await kysely
|
||||
.insertInto('user')
|
||||
.values({
|
||||
id: newId,
|
||||
name: data.displayName,
|
||||
email,
|
||||
emailVerified: 0,
|
||||
displayName: data.displayName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as any)
|
||||
.execute()
|
||||
user = await kysely
|
||||
.selectFrom('user')
|
||||
.selectAll()
|
||||
.where('email', '=', email)
|
||||
.executeTakeFirstOrThrow()
|
||||
}
|
||||
|
||||
const member = await kysely
|
||||
.insertInto('companyMember')
|
||||
.values({
|
||||
companyId: data.companyId,
|
||||
userId: (user as any).id,
|
||||
role: data.role,
|
||||
shares: data.shares ?? null,
|
||||
})
|
||||
.returning(['memberId'])
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
await audit.onInsert('companyMember', member.memberId, {
|
||||
companyId: data.companyId,
|
||||
userId: (user as any).id,
|
||||
role: data.role,
|
||||
shares: data.shares ?? null,
|
||||
})
|
||||
|
||||
return { memberId: member.memberId }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const ListMembersInput = z.object({ companyId: z.string() })
|
||||
export const ListMembersOutput = z.array(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
userId: z.string(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
role: z.enum(['managing_director', 'shareholder', 'angel']),
|
||||
shares: z.number().nullable(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const listMembers = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'List all members of a company. Caller must be a member.',
|
||||
input: ListMembersInput,
|
||||
output: ListMembersOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely }, { companyId }) => {
|
||||
const rows = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.innerJoin('user', 'user.id', 'companyMember.userId')
|
||||
.select([
|
||||
'companyMember.memberId as memberId',
|
||||
'user.id as userId',
|
||||
'user.email as email',
|
||||
'user.displayName as displayName',
|
||||
'companyMember.role as role',
|
||||
'companyMember.shares as shares',
|
||||
])
|
||||
.where('companyMember.companyId', '=', companyId)
|
||||
.orderBy('companyMember.createdAt', 'asc')
|
||||
.execute()
|
||||
return rows as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isManagingDirector } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const RemoveMemberInput = z.object({ companyId: z.string(), memberId: z.string() })
|
||||
export const RemoveMemberOutput = z.object({ ok: z.literal(true) })
|
||||
|
||||
export const removeMember = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Remove a member from a company. Caller must be managing_director.',
|
||||
input: RemoveMemberInput,
|
||||
output: RemoveMemberOutput,
|
||||
permissions: { md: [isManagingDirector] },
|
||||
func: async ({ kysely, audit }, { companyId, memberId }) => {
|
||||
await kysely
|
||||
.deleteFrom('companyMember')
|
||||
.where('memberId', '=', memberId)
|
||||
.where('companyId', '=', companyId)
|
||||
.execute()
|
||||
await audit.onDelete('companyMember', memberId)
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isManagingDirector } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const ArchiveResolutionInput = z.object({ resolutionId: z.string() })
|
||||
export const ArchiveResolutionOutput = z.object({ ok: z.literal(true) })
|
||||
|
||||
export const archiveResolution = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Archive a resolution. Caller must be managing_director.',
|
||||
input: ArchiveResolutionInput,
|
||||
output: ArchiveResolutionOutput,
|
||||
permissions: { md: [isManagingDirector] },
|
||||
func: async ({ kysely, audit }, { resolutionId }) => {
|
||||
await kysely
|
||||
.updateTable('resolution')
|
||||
.set({ status: 'archived' })
|
||||
.where('resolutionId', '=', resolutionId)
|
||||
.execute()
|
||||
await audit.onUpdate('resolution', resolutionId, { status: 'archived' })
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
import { getTemplate } from '../../templates/index.js'
|
||||
|
||||
export const CreateResolutionInput = z.object({
|
||||
companyId: z.string(),
|
||||
templateKey: z.string(),
|
||||
title: z.string().min(2),
|
||||
fieldValues: z.record(z.string(), z.unknown()).default({}),
|
||||
})
|
||||
|
||||
export const CreateResolutionOutput = z.object({ resolutionId: z.string() })
|
||||
|
||||
export const createResolution = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Create a draft resolution. Caller must be a company member.',
|
||||
input: CreateResolutionInput,
|
||||
output: CreateResolutionOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely, audit }, data, { session }) => {
|
||||
const template = getTemplate(data.templateKey)
|
||||
if (!template) throw new Error(`Unknown template: ${data.templateKey}`)
|
||||
|
||||
const row = await kysely
|
||||
.insertInto('resolution')
|
||||
.values({
|
||||
companyId: data.companyId,
|
||||
templateKey: data.templateKey,
|
||||
title: data.title,
|
||||
status: 'draft',
|
||||
fieldValues: JSON.stringify(data.fieldValues) as any,
|
||||
createdBy: session!.userId,
|
||||
})
|
||||
.returning(['resolutionId'])
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
await audit.onInsert('resolution', row.resolutionId, {
|
||||
companyId: data.companyId,
|
||||
templateKey: data.templateKey,
|
||||
title: data.title,
|
||||
status: 'draft',
|
||||
fieldValues: data.fieldValues,
|
||||
})
|
||||
return { resolutionId: row.resolutionId }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const GetResolutionPdfUrlInput = z.object({ resolutionId: z.string() })
|
||||
export const GetResolutionPdfUrlOutput = z.object({ url: z.string() })
|
||||
|
||||
export const getResolutionPdfUrl = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Return a signed URL for the resolution PDF. Caller must be a member.',
|
||||
input: GetResolutionPdfUrlInput,
|
||||
output: GetResolutionPdfUrlOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely, content }, { resolutionId }) => {
|
||||
const r = await kysely
|
||||
.selectFrom('resolution')
|
||||
.select(['pdfKey'])
|
||||
.where('resolutionId', '=', resolutionId)
|
||||
.executeTakeFirstOrThrow()
|
||||
if (!r.pdfKey) throw new Error('Resolution has no PDF yet')
|
||||
const url = await content.signContentKey({
|
||||
bucket: 'resolutions',
|
||||
contentKey: r.pdfKey,
|
||||
dateLessThan: new Date(Date.now() + 3600 * 1000),
|
||||
})
|
||||
return { url }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const GetResolutionInput = z.object({ resolutionId: z.string() })
|
||||
export const GetResolutionOutput = z.object({
|
||||
resolutionId: z.string(),
|
||||
companyId: z.string(),
|
||||
templateKey: z.string(),
|
||||
title: z.string(),
|
||||
status: z.enum(['draft', 'published', 'archived']),
|
||||
fieldValues: z.record(z.string(), z.unknown()),
|
||||
pdfKey: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
publishedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const getResolution = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'Fetch a resolution by id.',
|
||||
input: GetResolutionInput,
|
||||
output: GetResolutionOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely }, { resolutionId }) => {
|
||||
const r = await kysely
|
||||
.selectFrom('resolution')
|
||||
.selectAll()
|
||||
.where('resolutionId', '=', resolutionId)
|
||||
.executeTakeFirstOrThrow()
|
||||
return {
|
||||
resolutionId: r.resolutionId,
|
||||
companyId: r.companyId,
|
||||
templateKey: r.templateKey,
|
||||
title: r.title,
|
||||
status: r.status,
|
||||
fieldValues: (r.fieldValues as any) ?? {},
|
||||
pdfKey: r.pdfKey,
|
||||
createdAt: r.createdAt,
|
||||
publishedAt: r.publishedAt,
|
||||
} as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isCompanyMember } from '../../lib/auth-helpers.js'
|
||||
|
||||
export const ListResolutionsInput = z.object({
|
||||
companyId: z.string(),
|
||||
status: z.enum(['draft', 'published', 'archived']).optional(),
|
||||
})
|
||||
export const ListResolutionsOutput = z.array(
|
||||
z.object({
|
||||
resolutionId: z.string(),
|
||||
title: z.string(),
|
||||
templateKey: z.string(),
|
||||
status: z.enum(['draft', 'published', 'archived']),
|
||||
createdAt: z.string(),
|
||||
publishedAt: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const listResolutions = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'List resolutions for a company, optionally filtered by status.',
|
||||
input: ListResolutionsInput,
|
||||
output: ListResolutionsOutput,
|
||||
permissions: { member: [isCompanyMember] },
|
||||
func: async ({ kysely }, { companyId, status }) => {
|
||||
let q = kysely
|
||||
.selectFrom('resolution')
|
||||
.select([
|
||||
'resolutionId',
|
||||
'title',
|
||||
'templateKey',
|
||||
'status',
|
||||
'createdAt',
|
||||
'publishedAt',
|
||||
])
|
||||
.where('companyId', '=', companyId)
|
||||
if (status) q = q.where('status', '=', status)
|
||||
return (await q.orderBy('createdAt', 'desc').execute()) as any
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { isManagingDirector } from '../../lib/auth-helpers.js'
|
||||
import { getTemplate } from '../../templates/index.js'
|
||||
|
||||
export const PublishResolutionInput = z.object({ resolutionId: z.string() })
|
||||
export const PublishResolutionOutput = z.object({ resolutionId: z.string(), pdfKey: z.string() })
|
||||
|
||||
export const publishResolution = pikkuFunc({
|
||||
expose: true,
|
||||
description:
|
||||
'Render the resolution to PDF, mark as published. Caller must be managing_director.',
|
||||
input: PublishResolutionInput,
|
||||
output: PublishResolutionOutput,
|
||||
permissions: { md: [isManagingDirector] },
|
||||
func: async ({ kysely, browser, content, audit }, { resolutionId }) => {
|
||||
const r = await kysely
|
||||
.selectFrom('resolution')
|
||||
.selectAll()
|
||||
.where('resolutionId', '=', resolutionId)
|
||||
.executeTakeFirstOrThrow()
|
||||
if (r.status !== 'draft') {
|
||||
throw new Error('Only draft resolutions can be published')
|
||||
}
|
||||
|
||||
const template = getTemplate(r.templateKey)
|
||||
if (!template) throw new Error(`Unknown template: ${r.templateKey}`)
|
||||
|
||||
const company = await kysely
|
||||
.selectFrom('company')
|
||||
.selectAll()
|
||||
.where('companyId', '=', r.companyId)
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
const members = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.innerJoin('user', 'user.id', 'companyMember.userId')
|
||||
.select([
|
||||
'user.displayName as displayName',
|
||||
'companyMember.role as role',
|
||||
'companyMember.shares as shares',
|
||||
])
|
||||
.where('companyMember.companyId', '=', r.companyId)
|
||||
.execute()
|
||||
|
||||
const createdBy = await kysely
|
||||
.selectFrom('user')
|
||||
.select(['displayName', 'email'])
|
||||
.where('id', '=', r.createdBy)
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
const rawValues = r.fieldValues as unknown
|
||||
const values =
|
||||
typeof rawValues === 'string'
|
||||
? (JSON.parse(rawValues || '{}') as Record<string, unknown>)
|
||||
: ((rawValues as Record<string, unknown>) ?? {})
|
||||
|
||||
const html = template.render({
|
||||
values,
|
||||
company: {
|
||||
name: company.name,
|
||||
registryNo: company.registryNo,
|
||||
address: company.address,
|
||||
},
|
||||
members: members as any,
|
||||
createdBy,
|
||||
date: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Render the resolution HTML to a PDF via the browser service (node
|
||||
// puppeteer locally / Cloudflare Browser Rendering on deploy — same API),
|
||||
// then store the bytes in R2 through the content service. Keep the browser
|
||||
// session warm for reuse; close only the page.
|
||||
const key = `${r.companyId}/${r.resolutionId}.pdf`
|
||||
const session = await browser.acquire({ keepAlive: 60_000 })
|
||||
const page = await session.browser.newPage()
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' })
|
||||
const pdfBytes = await page.pdf({
|
||||
format: 'A4',
|
||||
margin: { top: '24mm', bottom: '24mm', left: '20mm', right: '20mm' },
|
||||
printBackground: true,
|
||||
})
|
||||
// Web ReadableStream (not node:stream) so this runs on CF Workers too.
|
||||
const stream = new Response(pdfBytes).body
|
||||
if (!stream) throw new Error('Failed to stream rendered PDF')
|
||||
await content.writeFile({ bucket: 'resolutions', key, stream })
|
||||
} finally {
|
||||
await page.close()
|
||||
await session.release()
|
||||
}
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
await kysely
|
||||
.updateTable('resolution')
|
||||
.set({ status: 'published', pdfKey: key, publishedAt: publishedAt as any })
|
||||
.where('resolutionId', '=', resolutionId)
|
||||
.execute()
|
||||
|
||||
await audit.onUpdate('resolution', resolutionId, {
|
||||
status: 'published',
|
||||
pdfKey: key,
|
||||
publishedAt,
|
||||
})
|
||||
return { resolutionId, pdfKey: key }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { pikkuFunc } from '#pikku/pikku-types.gen.js'
|
||||
import { listTemplates } from '../../templates/index.js'
|
||||
|
||||
export const ListResolutionTemplatesOutput = z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
titleDe: z.string(),
|
||||
titleEn: z.string(),
|
||||
descriptionDe: z.string(),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
labelDe: z.string(),
|
||||
labelEn: z.string(),
|
||||
type: z.enum(['text', 'textarea', 'number', 'date', 'currency']),
|
||||
required: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
export const listResolutionTemplates = pikkuFunc({
|
||||
expose: true,
|
||||
description: 'List all available resolution templates.',
|
||||
output: ListResolutionTemplatesOutput,
|
||||
func: async () => {
|
||||
return listTemplates().map((t) => ({
|
||||
key: t.key,
|
||||
titleDe: t.titleDe,
|
||||
titleEn: t.titleEn,
|
||||
descriptionDe: t.descriptionDe,
|
||||
fields: t.fields,
|
||||
})) as any
|
||||
},
|
||||
})
|
||||
61
packages/functions/src/lib/auth-helpers.ts
Normal file
61
packages/functions/src/lib/auth-helpers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { pikkuPermission } from '#pikku/pikku-types.gen.js'
|
||||
import type { Kysely } from 'kysely'
|
||||
import type { DB } from '../types/db.types.js'
|
||||
|
||||
async function resolveCompanyId(
|
||||
kysely: Kysely<DB>,
|
||||
data: any,
|
||||
): Promise<string | undefined> {
|
||||
if (typeof data?.companyId === 'string') return data.companyId
|
||||
if (typeof data?.resolutionId === 'string') {
|
||||
const r = await kysely
|
||||
.selectFrom('resolution')
|
||||
.select(['companyId'])
|
||||
.where('resolutionId', '=', data.resolutionId)
|
||||
.executeTakeFirst()
|
||||
return r?.companyId
|
||||
}
|
||||
if (typeof data?.memberId === 'string') {
|
||||
const m = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.select(['companyId'])
|
||||
.where('memberId', '=', data.memberId)
|
||||
.executeTakeFirst()
|
||||
return m?.companyId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function lookupRole(
|
||||
kysely: Kysely<DB>,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
): Promise<'managing_director' | 'shareholder' | 'angel' | null> {
|
||||
const m = await kysely
|
||||
.selectFrom('companyMember')
|
||||
.select(['role'])
|
||||
.where('companyId', '=', companyId)
|
||||
.where('userId', '=', userId)
|
||||
.executeTakeFirst()
|
||||
return (m?.role as any) ?? null
|
||||
}
|
||||
|
||||
export const isCompanyMember = pikkuPermission<any>(
|
||||
async ({ kysely }, data, { session }) => {
|
||||
if (!session?.userId) return false
|
||||
const companyId = await resolveCompanyId(kysely, data)
|
||||
if (!companyId) return false
|
||||
const role = await lookupRole(kysely, companyId, session.userId)
|
||||
return role !== null
|
||||
},
|
||||
)
|
||||
|
||||
export const isManagingDirector = pikkuPermission<any>(
|
||||
async ({ kysely }, data, { session }) => {
|
||||
if (!session?.userId) return false
|
||||
const companyId = await resolveCompanyId(kysely, data)
|
||||
if (!companyId) return false
|
||||
const role = await lookupRole(kysely, companyId, session.userId)
|
||||
return role === 'managing_director'
|
||||
},
|
||||
)
|
||||
68
packages/functions/src/lib/password.ts
Normal file
68
packages/functions/src/lib/password.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// PBKDF2 password hashing using Web Crypto — works in Node, Cloudflare Workers,
|
||||
// and the browser without any extra dependency.
|
||||
|
||||
const ITERATIONS = 100_000
|
||||
const HASH = 'SHA-256'
|
||||
const KEY_LEN_BITS = 256
|
||||
const SALT_BYTES = 16
|
||||
|
||||
const enc = new TextEncoder()
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
)
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: HASH },
|
||||
key,
|
||||
KEY_LEN_BITS,
|
||||
)
|
||||
return new Uint8Array(bits)
|
||||
}
|
||||
|
||||
/** Returns `pbkdf2:<iterations>:<saltHex>:<hashHex>`. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
if (!password || password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters.')
|
||||
}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES))
|
||||
const hash = await pbkdf2(password, salt, ITERATIONS)
|
||||
return `pbkdf2:${ITERATIONS}:${toHex(salt)}:${toHex(hash)}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string | null): Promise<boolean> {
|
||||
if (!stored) return false
|
||||
const parts = stored.split(':')
|
||||
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false
|
||||
const iterations = Number(parts[1])
|
||||
if (!Number.isFinite(iterations) || iterations <= 0) return false
|
||||
const salt = fromHex(parts[2])
|
||||
const expected = parts[3]
|
||||
const computed = await pbkdf2(password, salt, iterations)
|
||||
return timingSafeEqualHex(toHex(computed), expected)
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
}
|
||||
return diff === 0
|
||||
}
|
||||
57
packages/functions/src/services.ts
Normal file
57
packages/functions/src/services.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
JsonConsoleLogger,
|
||||
LocalSecretService,
|
||||
LocalVariablesService,
|
||||
} from '@pikku/core/services'
|
||||
import { pikkuServices } from '../.pikku/pikku-types.gen.js'
|
||||
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
|
||||
import { LibsqlWebDialect } from '@pikku/kysely-sqlite'
|
||||
import { Kysely, CamelCasePlugin } from 'kysely'
|
||||
import { LocalBrowserService } from '@pikku/browser'
|
||||
import type { DB } from './types/db.types.js'
|
||||
import './wire-services.js'
|
||||
|
||||
export const createSingletonServices = pikkuServices(async (
|
||||
config,
|
||||
existingServices,
|
||||
) => {
|
||||
const variables =
|
||||
existingServices?.variables ?? new LocalVariablesService()
|
||||
const secrets =
|
||||
existingServices?.secrets ?? new LocalSecretService(variables)
|
||||
const logger = existingServices?.logger ?? new JsonConsoleLogger()
|
||||
const schema =
|
||||
existingServices?.schema ?? new CFWorkerSchemaService(logger)
|
||||
|
||||
let kysely = existingServices?.kysely as Kysely<DB> | undefined
|
||||
if (!kysely) {
|
||||
const databaseUrl = await variables.get('DATABASE_URL')
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
'DATABASE_URL not set — in dev, enable `dev.db` in your pikku config; in prod, Fabric injects DATABASE_URL',
|
||||
)
|
||||
}
|
||||
kysely = new Kysely<DB>({
|
||||
dialect: new LibsqlWebDialect({ url: databaseUrl }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
})
|
||||
}
|
||||
|
||||
const content = existingServices?.content
|
||||
// On CF the browserContributor injects a Cloudflare Browser Rendering-backed
|
||||
// service; locally/sandbox/server we fall back to the node-puppeteer pool.
|
||||
const browser =
|
||||
existingServices?.browser ?? new LocalBrowserService({ logger })
|
||||
|
||||
return {
|
||||
...(existingServices ?? {}),
|
||||
config,
|
||||
variables,
|
||||
secrets,
|
||||
logger,
|
||||
schema,
|
||||
kysely,
|
||||
content,
|
||||
browser,
|
||||
}
|
||||
})
|
||||
41
packages/functions/src/services/audit.service.ts
Normal file
41
packages/functions/src/services/audit.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Kysely } from 'kysely'
|
||||
import type { DB } from '../../db/schema.js'
|
||||
|
||||
export type AuditAction = 'INSERT' | 'UPDATE' | 'DELETE'
|
||||
|
||||
export class AuditService {
|
||||
constructor(
|
||||
private readonly kysely: Kysely<DB>,
|
||||
private readonly userId: string | null
|
||||
) {}
|
||||
|
||||
async onInsert(table: string, recordId: string, data?: Record<string, unknown>) {
|
||||
await this.write(table, recordId, 'INSERT', data)
|
||||
}
|
||||
|
||||
async onUpdate(table: string, recordId: string, data: Record<string, unknown>) {
|
||||
await this.write(table, recordId, 'UPDATE', data)
|
||||
}
|
||||
|
||||
async onDelete(table: string, recordId: string) {
|
||||
await this.write(table, recordId, 'DELETE')
|
||||
}
|
||||
|
||||
private async write(
|
||||
table: string,
|
||||
recordId: string,
|
||||
action: AuditAction,
|
||||
data?: Record<string, unknown>
|
||||
) {
|
||||
await this.kysely
|
||||
.insertInto('auditLog')
|
||||
.values({
|
||||
tableName: table,
|
||||
recordId,
|
||||
action,
|
||||
userId: this.userId,
|
||||
changedFields: data == null ? null : (JSON.stringify(data) as any),
|
||||
} as any)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
60
packages/functions/src/templates/annual-budget-approval.ts
Normal file
60
packages/functions/src/templates/annual-budget-approval.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
formatEur,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
fiscalYear: z.string().min(4),
|
||||
totalBudget: z.coerce.number().nonnegative(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const annualBudgetApproval: ResolutionTemplate = {
|
||||
key: 'annual_budget_approval',
|
||||
titleDe: 'Beschluss zur Genehmigung des Jahresbudgets',
|
||||
titleEn: 'Annual budget approval',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss zur Feststellung und Genehmigung des Jahresbudgets der Gesellschaft.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'fiscalYear', labelDe: 'Geschäftsjahr', labelEn: 'Fiscal year', type: 'text', required: true },
|
||||
{ key: 'totalBudget', labelDe: 'Gesamtbudget (EUR)', labelEn: 'Total budget (EUR)', type: 'currency', required: true },
|
||||
{ key: 'notes', labelDe: 'Anmerkungen', labelEn: 'Notes', type: 'textarea' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Genehmigung des Jahresbudgets für das Geschäftsjahr <strong>${escapeHtml(v.fiscalYear)}</strong>.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<p>Die Gesellschafter beschließen einstimmig, das Jahresbudget in Höhe von
|
||||
<strong>${escapeHtml(formatEur(v.totalBudget))}</strong> für das Geschäftsjahr
|
||||
${escapeHtml(v.fiscalYear)} festzustellen und zu genehmigen.</p>
|
||||
${v.notes ? `<p><em>${escapeHtml(v.notes)}</em></p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Jahresbudget', body)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
formatEur,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
fiscalYear: z.string().min(4),
|
||||
balanceSheetTotal: z.coerce.number().nonnegative(),
|
||||
netResult: z.coerce.number(),
|
||||
approvalDate: z.string().min(8),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const annualFinancialsApproval: ResolutionTemplate = {
|
||||
key: 'annual_financials_approval',
|
||||
titleDe: 'Feststellung des Jahresabschlusses',
|
||||
titleEn: 'Annual financial statements approval',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss zur Feststellung des Jahresabschlusses gemäß § 46 Nr. 1 GmbHG.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'fiscalYear', labelDe: 'Geschäftsjahr', labelEn: 'Fiscal year', type: 'text', required: true },
|
||||
{ key: 'balanceSheetTotal', labelDe: 'Bilanzsumme (EUR)', labelEn: 'Balance sheet total (EUR)', type: 'currency', required: true },
|
||||
{ key: 'netResult', labelDe: 'Jahresergebnis (EUR)', labelEn: 'Net result (EUR)', type: 'currency', required: true },
|
||||
{ key: 'approvalDate', labelDe: 'Datum der Feststellung', labelEn: 'Approval date', type: 'date', required: true },
|
||||
{ key: 'notes', labelDe: 'Anmerkungen', labelEn: 'Notes', type: 'textarea' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const profitOrLoss = v.netResult >= 0 ? 'Jahresüberschuss' : 'Jahresfehlbetrag'
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Feststellung des Jahresabschlusses für das Geschäftsjahr
|
||||
<strong>${escapeHtml(v.fiscalYear)}</strong> gemäß § 46 Nr. 1 GmbHG.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<p>Die Gesellschafter stellen den Jahresabschluss zum
|
||||
<strong>${escapeHtml(formatDateDe(v.approvalDate))}</strong> fest mit einer
|
||||
Bilanzsumme von <strong>${escapeHtml(formatEur(v.balanceSheetTotal))}</strong>
|
||||
und einem ${escapeHtml(profitOrLoss)} von
|
||||
<strong>${escapeHtml(formatEur(v.netResult))}</strong>.</p>
|
||||
${v.notes ? `<p><em>${escapeHtml(v.notes)}</em></p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Feststellung Jahresabschluss', body)
|
||||
},
|
||||
}
|
||||
77
packages/functions/src/templates/capital-increase.ts
Normal file
77
packages/functions/src/templates/capital-increase.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
formatEur,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
currentCapital: z.coerce.number().nonnegative(),
|
||||
increaseAmount: z.coerce.number().positive(),
|
||||
newCapital: z.coerce.number().positive(),
|
||||
contributionType: z.enum(['cash', 'in_kind']).default('cash'),
|
||||
effectiveDate: z.string().min(8),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
const contributionLabel = (type: 'cash' | 'in_kind'): string =>
|
||||
type === 'cash' ? 'Bareinlage' : 'Sacheinlage'
|
||||
|
||||
export const capitalIncrease: ResolutionTemplate = {
|
||||
key: 'capital_increase',
|
||||
titleDe: 'Beschluss zur Kapitalerhöhung',
|
||||
titleEn: 'Capital increase',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss über die Erhöhung des Stammkapitals gemäß §§ 55 ff. GmbHG. Beurkundungspflichtig.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'currentCapital', labelDe: 'Bisheriges Stammkapital (EUR)', labelEn: 'Current share capital (EUR)', type: 'currency', required: true },
|
||||
{ key: 'increaseAmount', labelDe: 'Erhöhungsbetrag (EUR)', labelEn: 'Increase amount (EUR)', type: 'currency', required: true },
|
||||
{ key: 'newCapital', labelDe: 'Neues Stammkapital (EUR)', labelEn: 'New share capital (EUR)', type: 'currency', required: true },
|
||||
{ key: 'contributionType', labelDe: 'Art der Einlage (cash | in_kind)', labelEn: 'Contribution type (cash | in_kind)', type: 'text', required: true },
|
||||
{ key: 'effectiveDate', labelDe: 'Wirksam ab', labelEn: 'Effective date', type: 'date', required: true },
|
||||
{ key: 'notes', labelDe: 'Anmerkungen', labelEn: 'Notes', type: 'textarea' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Erhöhung des Stammkapitals der Gesellschaft gemäß §§ 55 ff. GmbHG.
|
||||
Dieser Beschluss bedarf zu seiner Wirksamkeit der notariellen Beurkundung
|
||||
sowie der Eintragung in das Handelsregister.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<ul>
|
||||
<li>Bisheriges Stammkapital: <strong>${escapeHtml(formatEur(v.currentCapital))}</strong></li>
|
||||
<li>Erhöhung um: <strong>${escapeHtml(formatEur(v.increaseAmount))}</strong></li>
|
||||
<li>Neues Stammkapital: <strong>${escapeHtml(formatEur(v.newCapital))}</strong></li>
|
||||
<li>Art der Einlage: <strong>${escapeHtml(contributionLabel(v.contributionType))}</strong></li>
|
||||
<li>Wirksam ab: <strong>${escapeHtml(formatDateDe(v.effectiveDate))}</strong></li>
|
||||
</ul>
|
||||
<p>Die Geschäftsführung wird angewiesen, die Kapitalerhöhung unverzüglich
|
||||
zur Eintragung in das Handelsregister anzumelden.</p>
|
||||
${v.notes ? `<p><em>${escapeHtml(v.notes)}</em></p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Kapitalerhöhung', body)
|
||||
},
|
||||
}
|
||||
49
packages/functions/src/templates/index.ts
Normal file
49
packages/functions/src/templates/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod'
|
||||
import { annualBudgetApproval } from './annual-budget-approval.js'
|
||||
import { mdAppointment } from './md-appointment.js'
|
||||
import { annualFinancialsApproval } from './annual-financials-approval.js'
|
||||
import { profitDistribution } from './profit-distribution.js'
|
||||
import { mdDismissal } from './md-dismissal.js'
|
||||
import { capitalIncrease } from './capital-increase.js'
|
||||
import { shareTransferApproval } from './share-transfer-approval.js'
|
||||
|
||||
export interface ResolutionTemplate {
|
||||
key: string
|
||||
titleDe: string
|
||||
titleEn: string
|
||||
descriptionDe: string
|
||||
schema: z.ZodTypeAny
|
||||
fields: TemplateField[]
|
||||
render: (ctx: TemplateRenderContext) => string
|
||||
}
|
||||
|
||||
export interface TemplateField {
|
||||
key: string
|
||||
labelDe: string
|
||||
labelEn: string
|
||||
type: 'text' | 'textarea' | 'number' | 'date' | 'currency'
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateRenderContext {
|
||||
values: Record<string, unknown>
|
||||
company: { name: string; registryNo?: string | null; address?: string | null }
|
||||
members: Array<{ displayName: string; role: string; shares: number | null }>
|
||||
createdBy: { displayName: string; email: string }
|
||||
date: string
|
||||
}
|
||||
|
||||
const registry: Record<string, ResolutionTemplate> = {
|
||||
[annualBudgetApproval.key]: annualBudgetApproval,
|
||||
[annualFinancialsApproval.key]: annualFinancialsApproval,
|
||||
[profitDistribution.key]: profitDistribution,
|
||||
[mdAppointment.key]: mdAppointment,
|
||||
[mdDismissal.key]: mdDismissal,
|
||||
[capitalIncrease.key]: capitalIncrease,
|
||||
[shareTransferApproval.key]: shareTransferApproval,
|
||||
}
|
||||
|
||||
export const listTemplates = (): ResolutionTemplate[] => Object.values(registry)
|
||||
|
||||
export const getTemplate = (key: string): ResolutionTemplate | undefined =>
|
||||
registry[key]
|
||||
66
packages/functions/src/templates/md-appointment.ts
Normal file
66
packages/functions/src/templates/md-appointment.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
appointeeName: z.string().min(2),
|
||||
appointeeAddress: z.string().min(2),
|
||||
effectiveDate: z.string().min(8),
|
||||
soleRepresentation: z.coerce.boolean().optional(),
|
||||
selfDealingExempt: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export const mdAppointment: ResolutionTemplate = {
|
||||
key: 'md_appointment',
|
||||
titleDe: 'Beschluss zur Bestellung eines Geschäftsführers',
|
||||
titleEn: 'Managing director appointment',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss zur Bestellung einer Person zum Geschäftsführer der Gesellschaft.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'appointeeName', labelDe: 'Name des Geschäftsführers', labelEn: 'Appointee name', type: 'text', required: true },
|
||||
{ key: 'appointeeAddress', labelDe: 'Anschrift', labelEn: 'Address', type: 'textarea', required: true },
|
||||
{ key: 'effectiveDate', labelDe: 'Wirksam ab', labelEn: 'Effective date', type: 'date', required: true },
|
||||
{ key: 'soleRepresentation', labelDe: 'Einzelvertretungsbefugnis', labelEn: 'Sole representation', type: 'text' },
|
||||
{ key: 'selfDealingExempt', labelDe: 'Befreiung von § 181 BGB', labelEn: 'Exempt from § 181 BGB', type: 'text' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Bestellung von <strong>${escapeHtml(v.appointeeName)}</strong>,
|
||||
${escapeHtml(v.appointeeAddress)}, zum Geschäftsführer der Gesellschaft
|
||||
mit Wirkung zum <strong>${escapeHtml(formatDateDe(v.effectiveDate))}</strong>.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<ul>
|
||||
<li>${escapeHtml(v.appointeeName)} wird zum Geschäftsführer bestellt.</li>
|
||||
${v.soleRepresentation ? '<li>Einzelvertretungsbefugnis wird erteilt.</li>' : ''}
|
||||
${v.selfDealingExempt ? '<li>Befreiung von den Beschränkungen des § 181 BGB wird erteilt.</li>' : ''}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Bestellung Geschäftsführer', body)
|
||||
},
|
||||
}
|
||||
66
packages/functions/src/templates/md-dismissal.ts
Normal file
66
packages/functions/src/templates/md-dismissal.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
dismisseeName: z.string().min(2),
|
||||
effectiveDate: z.string().min(8),
|
||||
reason: z.string().optional(),
|
||||
releaseFromContract: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export const mdDismissal: ResolutionTemplate = {
|
||||
key: 'md_dismissal',
|
||||
titleDe: 'Beschluss zur Abberufung eines Geschäftsführers',
|
||||
titleEn: 'Managing director dismissal',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss zur Abberufung einer Person aus der Geschäftsführung gemäß § 38 GmbHG.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'dismisseeName', labelDe: 'Name des Geschäftsführers', labelEn: 'Director name', type: 'text', required: true },
|
||||
{ key: 'effectiveDate', labelDe: 'Wirksam ab', labelEn: 'Effective date', type: 'date', required: true },
|
||||
{ key: 'reason', labelDe: 'Begründung', labelEn: 'Reason', type: 'textarea' },
|
||||
{ key: 'releaseFromContract', labelDe: 'Anstellungsvertrag wird aufgehoben', labelEn: 'Employment contract terminated', type: 'text' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Abberufung von <strong>${escapeHtml(v.dismisseeName)}</strong> als
|
||||
Geschäftsführer der Gesellschaft mit Wirkung zum
|
||||
<strong>${escapeHtml(formatDateDe(v.effectiveDate))}</strong> gemäß § 38 GmbHG.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<ul>
|
||||
<li>${escapeHtml(v.dismisseeName)} wird mit Wirkung zum ${escapeHtml(formatDateDe(v.effectiveDate))} aus der Geschäftsführung abberufen.</li>
|
||||
${v.releaseFromContract ? '<li>Der zugrundeliegende Anstellungsvertrag wird einvernehmlich aufgehoben.</li>' : ''}
|
||||
<li>Der Geschäftsführer wird angewiesen, alle Geschäftsunterlagen, Schlüssel und Vermögensgegenstände der Gesellschaft unverzüglich zurückzugeben.</li>
|
||||
<li>Die Anmeldung zum Handelsregister wird veranlasst.</li>
|
||||
</ul>
|
||||
${v.reason ? `<p><strong>Begründung:</strong> ${escapeHtml(v.reason)}</p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Abberufung Geschäftsführer', body)
|
||||
},
|
||||
}
|
||||
71
packages/functions/src/templates/profit-distribution.ts
Normal file
71
packages/functions/src/templates/profit-distribution.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
formatEur,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
fiscalYear: z.string().min(4),
|
||||
distributableProfit: z.coerce.number().nonnegative(),
|
||||
distributedAmount: z.coerce.number().nonnegative(),
|
||||
retainedAmount: z.coerce.number().nonnegative(),
|
||||
paymentDate: z.string().min(8),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const profitDistribution: ResolutionTemplate = {
|
||||
key: 'profit_distribution',
|
||||
titleDe: 'Beschluss zur Gewinnverwendung',
|
||||
titleEn: 'Profit distribution',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss über die Verwendung des Jahresergebnisses gemäß § 29 GmbHG.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'fiscalYear', labelDe: 'Geschäftsjahr', labelEn: 'Fiscal year', type: 'text', required: true },
|
||||
{ key: 'distributableProfit', labelDe: 'Bilanzgewinn (EUR)', labelEn: 'Distributable profit (EUR)', type: 'currency', required: true },
|
||||
{ key: 'distributedAmount', labelDe: 'Auszuschüttender Betrag (EUR)', labelEn: 'Amount to distribute (EUR)', type: 'currency', required: true },
|
||||
{ key: 'retainedAmount', labelDe: 'Gewinnvortrag (EUR)', labelEn: 'Retained amount (EUR)', type: 'currency', required: true },
|
||||
{ key: 'paymentDate', labelDe: 'Auszahlungsdatum', labelEn: 'Payment date', type: 'date', required: true },
|
||||
{ key: 'notes', labelDe: 'Anmerkungen', labelEn: 'Notes', type: 'textarea' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Verwendung des Jahresergebnisses für das Geschäftsjahr
|
||||
<strong>${escapeHtml(v.fiscalYear)}</strong> gemäß § 29 GmbHG.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<ul>
|
||||
<li>Bilanzgewinn: <strong>${escapeHtml(formatEur(v.distributableProfit))}</strong></li>
|
||||
<li>Auszuschüttender Betrag: <strong>${escapeHtml(formatEur(v.distributedAmount))}</strong></li>
|
||||
<li>Gewinnvortrag: <strong>${escapeHtml(formatEur(v.retainedAmount))}</strong></li>
|
||||
<li>Auszahlung am: <strong>${escapeHtml(formatDateDe(v.paymentDate))}</strong></li>
|
||||
</ul>
|
||||
<p>Die Ausschüttung erfolgt im Verhältnis der Geschäftsanteile.</p>
|
||||
${v.notes ? `<p><em>${escapeHtml(v.notes)}</em></p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Gewinnverwendung', body)
|
||||
},
|
||||
}
|
||||
83
packages/functions/src/templates/share-transfer-approval.ts
Normal file
83
packages/functions/src/templates/share-transfer-approval.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod'
|
||||
import type { ResolutionTemplate } from './index.js'
|
||||
import {
|
||||
baseDocument,
|
||||
escapeHtml,
|
||||
formatDateDe,
|
||||
formatEur,
|
||||
renderMembers,
|
||||
} from './shared.js'
|
||||
|
||||
const schema = z.object({
|
||||
sellerName: z.string().min(2),
|
||||
buyerName: z.string().min(2),
|
||||
buyerAddress: z.string().min(2),
|
||||
shareAmount: z.coerce.number().positive(),
|
||||
totalSharesAfter: z.coerce.number().nonnegative(),
|
||||
purchasePrice: z.coerce.number().nonnegative(),
|
||||
effectiveDate: z.string().min(8),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const shareTransferApproval: ResolutionTemplate = {
|
||||
key: 'share_transfer_approval',
|
||||
titleDe: 'Genehmigung der Anteilsübertragung',
|
||||
titleEn: 'Share transfer approval',
|
||||
descriptionDe:
|
||||
'Gesellschafterbeschluss zur Genehmigung der Übertragung von Geschäftsanteilen gemäß § 15 GmbHG i.V.m. der Satzung. Die Übertragung selbst ist beurkundungspflichtig.',
|
||||
schema,
|
||||
fields: [
|
||||
{ key: 'sellerName', labelDe: 'Veräußerer', labelEn: 'Seller', type: 'text', required: true },
|
||||
{ key: 'buyerName', labelDe: 'Erwerber', labelEn: 'Buyer', type: 'text', required: true },
|
||||
{ key: 'buyerAddress', labelDe: 'Anschrift des Erwerbers', labelEn: 'Buyer address', type: 'textarea', required: true },
|
||||
{ key: 'shareAmount', labelDe: 'Übertragener Anteil (EUR Nennbetrag)', labelEn: 'Transferred share (EUR nominal)', type: 'currency', required: true },
|
||||
{ key: 'totalSharesAfter', labelDe: 'Anteile des Erwerbers nach Übertragung (EUR Nennbetrag)', labelEn: 'Buyer total shares after transfer (EUR nominal)', type: 'currency', required: true },
|
||||
{ key: 'purchasePrice', labelDe: 'Kaufpreis (EUR)', labelEn: 'Purchase price (EUR)', type: 'currency', required: true },
|
||||
{ key: 'effectiveDate', labelDe: 'Wirksam ab', labelEn: 'Effective date', type: 'date', required: true },
|
||||
{ key: 'notes', labelDe: 'Anmerkungen', labelEn: 'Notes', type: 'textarea' },
|
||||
],
|
||||
render: ({ values, company, members, createdBy, date }) => {
|
||||
const v = schema.parse(values)
|
||||
const body = `
|
||||
<h1>Gesellschafterbeschluss</h1>
|
||||
<div class="meta">
|
||||
${escapeHtml(company.name)}${company.registryNo ? `, ${escapeHtml(company.registryNo)}` : ''}<br/>
|
||||
${company.address ? escapeHtml(company.address) + '<br/>' : ''}
|
||||
Beschlussdatum: ${escapeHtml(formatDateDe(date))}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gegenstand des Beschlusses</h2>
|
||||
<p>Genehmigung der Übertragung eines Geschäftsanteils im Nennbetrag von
|
||||
<strong>${escapeHtml(formatEur(v.shareAmount))}</strong> von
|
||||
<strong>${escapeHtml(v.sellerName)}</strong> auf
|
||||
<strong>${escapeHtml(v.buyerName)}</strong> gemäß § 15 GmbHG
|
||||
in Verbindung mit der Satzung der Gesellschaft.</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Beschluss</h2>
|
||||
<ul>
|
||||
<li>Veräußerer: <strong>${escapeHtml(v.sellerName)}</strong></li>
|
||||
<li>Erwerber: <strong>${escapeHtml(v.buyerName)}</strong>, ${escapeHtml(v.buyerAddress)}</li>
|
||||
<li>Übertragener Anteil (Nennbetrag): <strong>${escapeHtml(formatEur(v.shareAmount))}</strong></li>
|
||||
<li>Kaufpreis: <strong>${escapeHtml(formatEur(v.purchasePrice))}</strong></li>
|
||||
<li>Anteile des Erwerbers nach Übertragung (Nennbetrag): <strong>${escapeHtml(formatEur(v.totalSharesAfter))}</strong></li>
|
||||
<li>Wirksam ab: <strong>${escapeHtml(formatDateDe(v.effectiveDate))}</strong></li>
|
||||
</ul>
|
||||
<p>Die Wirksamkeit der Übertragung setzt eine notarielle Beurkundung des
|
||||
Übertragungsvertrages gemäß § 15 Abs. 3 GmbHG voraus. Die
|
||||
Geschäftsführung wird angewiesen, eine aktualisierte Gesellschafterliste
|
||||
beim Handelsregister einzureichen.</p>
|
||||
${v.notes ? `<p><em>${escapeHtml(v.notes)}</em></p>` : ''}
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Gesellschafter</h2>
|
||||
${renderMembers(members)}
|
||||
</div>
|
||||
<div class="section signature">
|
||||
<div class="line">${escapeHtml(createdBy.displayName)}<br/><small>${escapeHtml(createdBy.email)}</small></div>
|
||||
<div class="line">Ort, Datum</div>
|
||||
</div>
|
||||
`
|
||||
return baseDocument('Beschluss – Anteilsübertragung', body)
|
||||
},
|
||||
}
|
||||
64
packages/functions/src/templates/shared.ts
Normal file
64
packages/functions/src/templates/shared.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const escapeHtml = (s: unknown): string =>
|
||||
String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
export const formatEur = (n: unknown): string => {
|
||||
const num = typeof n === 'number' ? n : Number(n ?? 0)
|
||||
if (!Number.isFinite(num)) return '—'
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(num)
|
||||
}
|
||||
|
||||
export const formatDateDe = (d: string | Date): string => {
|
||||
const date = typeof d === 'string' ? new Date(d) : d
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export const baseDocument = (title: string, body: string): string => `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 24mm 20mm; }
|
||||
body { font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11pt; color: #111; line-height: 1.5; }
|
||||
h1 { font-size: 16pt; margin: 0 0 4mm 0; }
|
||||
h2 { font-size: 13pt; margin: 8mm 0 2mm 0; }
|
||||
.meta { color: #555; font-size: 10pt; margin-bottom: 8mm; }
|
||||
.section { margin: 6mm 0; }
|
||||
ul { margin: 2mm 0; padding-left: 6mm; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td, th { border: 1px solid #ddd; padding: 2mm 3mm; text-align: left; vertical-align: top; }
|
||||
.signature { margin-top: 16mm; display: flex; gap: 12mm; }
|
||||
.signature .line { flex: 1; border-top: 1px solid #333; padding-top: 2mm; font-size: 10pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export const renderMembers = (
|
||||
members: Array<{ displayName: string; role: string; shares: number | null }>,
|
||||
): string => {
|
||||
const rows = members
|
||||
.map(
|
||||
(m) =>
|
||||
`<tr><td>${escapeHtml(m.displayName)}</td><td>${escapeHtml(m.role)}</td><td>${m.shares ?? '—'}</td></tr>`,
|
||||
)
|
||||
.join('')
|
||||
return `<table>
|
||||
<thead><tr><th>Name</th><th>Rolle</th><th>Anteile</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`
|
||||
}
|
||||
1
packages/functions/src/types/db.types.ts
Normal file
1
packages/functions/src/types/db.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DB } from '../../db/schema.js'
|
||||
9
packages/functions/src/wire-services.ts
Normal file
9
packages/functions/src/wire-services.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { pikkuWireServices } from '../.pikku/pikku-types.gen.js'
|
||||
import { AuditService } from './services/audit.service.js'
|
||||
|
||||
export const createWireServices = pikkuWireServices(async (services, wire) => {
|
||||
const userId = wire.session?.userId ?? null
|
||||
return {
|
||||
audit: new AuditService(services.kysely, userId),
|
||||
}
|
||||
})
|
||||
51
packages/functions/src/wirings/auth.wiring.ts
Normal file
51
packages/functions/src/wirings/auth.wiring.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { admin, organization } from 'better-auth/plugins'
|
||||
import { actor, fabric } from '@pikku/better-auth'
|
||||
import { pikkuBetterAuth } from '#pikku/pikku-types.gen.js'
|
||||
|
||||
export const auth = pikkuBetterAuth(async ({ kysely, secrets, variables }) => {
|
||||
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
|
||||
// Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios
|
||||
// off for this deployment) — the actor plugin refuses all sign-ins without it.
|
||||
const SCENARIO_ACTOR_SECRET = await secrets
|
||||
.getSecret('SCENARIO_ACTOR_SECRET')
|
||||
.catch(() => undefined)
|
||||
// Fabric operator admin: the RSA public key the control plane's token is
|
||||
// verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto
|
||||
// every stage; locally it's simply absent, which disables /sign-in/fabric.
|
||||
const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')
|
||||
|
||||
return betterAuth({
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
database: { db: kysely as any, type: 'sqlite' },
|
||||
emailAndPassword: { enabled: true },
|
||||
session: { cookieCache: { enabled: true } },
|
||||
advanced: { database: { generateId: 'uuid' } },
|
||||
// organization(): org/team scoping with invitations (db/sqlite/0003).
|
||||
// admin(): list/setRole/ban/impersonate users (db/sqlite/0006-admin.sql).
|
||||
// actor(): synthetic scenario users (db/sqlite/0005-user-actor.sql).
|
||||
// fabric(): Fabric console operator sign-in (db/sqlite/0007-fabric.sql).
|
||||
plugins: [
|
||||
organization(),
|
||||
actor({ secret: SCENARIO_ACTOR_SECRET }),
|
||||
// New users must get a role the user.role CHECK
|
||||
// ('managing_director','shareholder','angel') accepts. The admin plugin
|
||||
// otherwise defaults them to 'user', which the CHECK rejects → every
|
||||
// sign-up fails with SQLITE_CONSTRAINT_CHECK. A self-signup user creates
|
||||
// a company as its managing director (create-company.function.ts), so
|
||||
// that's the right default.
|
||||
admin({ defaultRole: 'managing_director' }),
|
||||
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),
|
||||
],
|
||||
user: {
|
||||
additionalFields: {
|
||||
displayName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
input: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
28
packages/functions/src/wirings/cors.wiring.ts
Normal file
28
packages/functions/src/wirings/cors.wiring.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { addHTTPMiddleware } from '@pikku/core/http'
|
||||
import { authBearer, cors } from '@pikku/core/middleware'
|
||||
import { betterAuthStatelessSession } from '@pikku/better-auth'
|
||||
|
||||
addHTTPMiddleware('*', [
|
||||
cors({
|
||||
origin: ['http://localhost:7104', 'http://127.0.0.1:7104'],
|
||||
credentials: true,
|
||||
}),
|
||||
betterAuthStatelessSession({
|
||||
mapSession: (result) => ({
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
displayName: result.user.displayName ?? result.user.name ?? '',
|
||||
}),
|
||||
}),
|
||||
// External consoles (sandbox/woven-build view) can't carry the session cookie,
|
||||
// so they authenticate with `Authorization: Bearer <PIKKU_CONSOLE_TOKEN>`. The
|
||||
// CLI normally emits this alongside its session middleware, but our own global
|
||||
// session middleware above makes it step aside (pikku#754) — so add the
|
||||
// console-token bridge here or console:* RPCs 403 in the sandbox.
|
||||
authBearer({
|
||||
token: {
|
||||
secretId: 'PIKKU_CONSOLE_TOKEN',
|
||||
userSession: { userId: 'pikku-console-token' },
|
||||
},
|
||||
}),
|
||||
])
|
||||
1
packages/functions/tests/.gitignore
vendored
Normal file
1
packages/functions/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.test
|
||||
24
packages/functions/tests/package.json
Normal file
24
packages/functions/tests/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "tests",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Function test harness — Cucumber over in-process Pikku RPC with stubbed services.",
|
||||
"scripts": {
|
||||
"test": "node --env-file=.env.test node_modules/.bin/cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "node --env-file=.env.test node_modules/.bin/cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"coverage": "pikku tests coverage",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikku/cucumber": "0.12.9",
|
||||
"@pikku/kysely-node-sqlite": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@types/node": "^22",
|
||||
"c8": "^10.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
7
packages/functions/tests/tests/cucumber.mjs
Normal file
7
packages/functions/tests/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
0
packages/functions/tests/tests/features/.gitkeep
Normal file
0
packages/functions/tests/tests/features/.gitkeep
Normal file
7
packages/functions/tests/tests/support/hooks.ts
Normal file
7
packages/functions/tests/tests/support/hooks.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../../.pikku/pikku-bootstrap.gen.js'
|
||||
import { Before, After, BeforeAll, AfterAll, setDefaultTimeout, Given, When, Then } from '@cucumber/cucumber'
|
||||
import { registerHooks, registerCommonSteps } from '@pikku/cucumber'
|
||||
import { db } from './services.js'
|
||||
|
||||
registerHooks({ Before, After, BeforeAll, AfterAll, setDefaultTimeout }, db)
|
||||
registerCommonSteps({ Given, When, Then })
|
||||
44
packages/functions/tests/tests/support/services.ts
Normal file
44
packages/functions/tests/tests/support/services.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createNodeSqliteKysely, createCoercionPlugin } from '@pikku/kysely-node-sqlite'
|
||||
import { createDbUtils, type StubTracker } from '@pikku/cucumber'
|
||||
import { createConfig } from '../../../src/config.js'
|
||||
import { createSingletonServices } from '../../../src/services.js'
|
||||
import { coercionMap } from '../../../.pikku/db/coercion.gen.js'
|
||||
import type { DB } from '../../../.pikku/db/schema.js'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = (p: string) => resolve(__dirname, '../../../../..', p)
|
||||
|
||||
export const db = createDbUtils({
|
||||
migrationsDir: repoRoot('db/migrations'),
|
||||
seedFile: repoRoot('db/seed.sql'),
|
||||
})
|
||||
|
||||
type StubKysely = ReturnType<typeof createNodeSqliteKysely<DB>>
|
||||
|
||||
const REAL_SERVICES = (kysely: StubKysely) => ({ kysely })
|
||||
|
||||
export async function createStubServices(dbFile: string, tracker: StubTracker) {
|
||||
const kysely = createNodeSqliteKysely<DB>({
|
||||
filename: dbFile,
|
||||
camelCase: true,
|
||||
plugins: [createCoercionPlugin({ map: coercionMap })],
|
||||
})
|
||||
|
||||
const real = REAL_SERVICES(kysely)
|
||||
const injected = new Proxy(real as Record<string, unknown>, {
|
||||
get(target, prop: string) {
|
||||
if (prop in target) return target[prop]
|
||||
return tracker.stub(prop)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await createConfig()
|
||||
const services = await createSingletonServices(
|
||||
{ ...config, dev: { db: { file: dbFile } } } as never,
|
||||
injected as never,
|
||||
)
|
||||
|
||||
return { services, kysely }
|
||||
}
|
||||
5
packages/functions/tests/tests/support/world.ts
Normal file
5
packages/functions/tests/tests/support/world.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import { createFunctionWorld } from '@pikku/cucumber'
|
||||
import { createStubServices } from './services.js'
|
||||
|
||||
createFunctionWorld(World, setWorldConstructor, createStubServices)
|
||||
18
packages/functions/tests/tsconfig.json
Normal file
18
packages/functions/tests/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
12
packages/mantine-theme/package.json
Normal file
12
packages/mantine-theme/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@germantax/mantine-theme",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pikku/mantine": "^0.12.6"
|
||||
}
|
||||
}
|
||||
40
packages/mantine-theme/src/index.ts
Normal file
40
packages/mantine-theme/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createTheme, type MantineColorsTuple } from '@pikku/mantine/core'
|
||||
|
||||
const quorum: MantineColorsTuple = [
|
||||
'#EAF1ED',
|
||||
'#D5E6DB',
|
||||
'#B0CFBB',
|
||||
'#8FCBA9',
|
||||
'#4E9B72',
|
||||
'#2E6F54',
|
||||
'#1F5641',
|
||||
'#1A4837',
|
||||
'#143A2C',
|
||||
'#0E1F18',
|
||||
]
|
||||
|
||||
const amber: MantineColorsTuple = [
|
||||
'#FDF7EE',
|
||||
'#F6EEDD',
|
||||
'#F0E3C4',
|
||||
'#EBC57A',
|
||||
'#D4A84E',
|
||||
'#C09030',
|
||||
'#B08A3E',
|
||||
'#8A6A1E',
|
||||
'#6E5218',
|
||||
'#523D12',
|
||||
]
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: 'quorum',
|
||||
primaryShade: { light: 6, dark: 5 },
|
||||
colors: { quorum, amber },
|
||||
fontFamily: "'Public Sans', system-ui, sans-serif",
|
||||
fontFamilyMonospace: "'IBM Plex Mono', ui-monospace, Menlo, monospace",
|
||||
headings: {
|
||||
fontFamily: "'Source Serif 4', Georgia, serif",
|
||||
fontWeight: '600',
|
||||
},
|
||||
defaultRadius: 'sm',
|
||||
})
|
||||
Reference in New Issue
Block a user