feat: Add password reset functionality with request and reset forms
feat: Implement price list import feature with preview and apply options feat: Create price rules management page with CRUD operations feat: Develop quotes management page with itemized quotes and status tracking feat: Introduce organization registration page for new users feat: Build suppliers management page with detailed supplier information feat: Create users management page for inviting and managing roles chore: Add TypeScript configuration for improved type checking chore: Set up Vite configuration for development server and API proxy chore: Add Vite environment type definitions for better TypeScript support
This commit is contained in:
25
backend/Cargo.toml
Normal file
25
backend/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "companytool-backend"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
argon2 = "0.5"
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
companytool-shared-protocol = { path = "../shared-protocol" }
|
||||
dotenvy = "0.15"
|
||||
futures-util = "0.3"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p companytool-backend
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/companytool-backend /usr/local/bin/companytool-backend
|
||||
EXPOSE 8080
|
||||
CMD ["companytool-backend"]
|
||||
80
backend/company-migrations/0001_company_base.sql
Normal file
80
backend/company-migrations/0001_company_base.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.settings (
|
||||
key text primary key,
|
||||
value_ciphertext bytea not null,
|
||||
value_nonce bytea not null,
|
||||
value_key_id text not null,
|
||||
updated_by_user_id uuid,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists {schema}.roles (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
description text,
|
||||
is_system_role boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists {schema}.permissions (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
description text
|
||||
);
|
||||
|
||||
create table if not exists {schema}.role_permissions (
|
||||
role_id uuid not null references {schema}.roles(id) on delete cascade,
|
||||
permission_id uuid not null references {schema}.permissions(id) on delete cascade,
|
||||
primary key (role_id, permission_id)
|
||||
);
|
||||
|
||||
create table if not exists {schema}.user_roles (
|
||||
user_id uuid not null,
|
||||
role_id uuid not null references {schema}.roles(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (user_id, role_id)
|
||||
);
|
||||
|
||||
create table if not exists {schema}.number_ranges (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
pattern text not null,
|
||||
counter_value bigint not null default 0,
|
||||
counter_padding integer not null default 0,
|
||||
reset_rule text,
|
||||
is_active boolean not null default true,
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint number_ranges_pattern_has_counter check (position('{counter}' in pattern) > 0)
|
||||
);
|
||||
|
||||
create table if not exists {schema}.audit_log (
|
||||
id uuid primary key,
|
||||
actor_user_id uuid,
|
||||
action text not null,
|
||||
entity_type text not null,
|
||||
entity_id uuid,
|
||||
before_ciphertext bytea,
|
||||
before_nonce bytea,
|
||||
before_key_id text,
|
||||
after_ciphertext bytea,
|
||||
after_nonce bytea,
|
||||
after_key_id text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists {schema}.change_log (
|
||||
id uuid primary key,
|
||||
sequence bigserial not null unique,
|
||||
entity_type text not null,
|
||||
entity_id uuid not null,
|
||||
operation text not null,
|
||||
payload_ciphertext bytea not null,
|
||||
payload_nonce bytea not null,
|
||||
payload_key_id text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
created_by_user_id uuid
|
||||
);
|
||||
360
backend/company-migrations/0002_activity_price_invoice_rules.sql
Normal file
360
backend/company-migrations/0002_activity_price_invoice_rules.sql
Normal file
@@ -0,0 +1,360 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.customers (
|
||||
id uuid primary key,
|
||||
customer_number text unique,
|
||||
name_ciphertext bytea not null,
|
||||
name_nonce bytea not null,
|
||||
name_key_id text not null,
|
||||
status text not null default 'active',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint customers_status_valid check (status in ('active', 'inactive', 'blocked'))
|
||||
);
|
||||
|
||||
create table if not exists {schema}.suppliers (
|
||||
id uuid primary key,
|
||||
supplier_number text unique,
|
||||
name_ciphertext bytea not null,
|
||||
name_nonce bytea not null,
|
||||
name_key_id text not null,
|
||||
status text not null default 'active',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint suppliers_status_valid check (status in ('active', 'inactive', 'blocked'))
|
||||
);
|
||||
|
||||
create table if not exists {schema}.items (
|
||||
id uuid primary key,
|
||||
item_number text not null unique,
|
||||
name_ciphertext bytea not null,
|
||||
name_nonce bytea not null,
|
||||
name_key_id text not null,
|
||||
unit text not null default 'Stk',
|
||||
tax_rate numeric(7, 4) not null default 19.0,
|
||||
default_purchase_price numeric(14, 4),
|
||||
default_sales_price numeric(14, 4),
|
||||
status text not null default 'active',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint items_status_valid check (status in ('active', 'inactive', 'blocked')),
|
||||
constraint items_tax_rate_non_negative check (tax_rate >= 0),
|
||||
constraint items_default_purchase_price_non_negative check (
|
||||
default_purchase_price is null or default_purchase_price >= 0
|
||||
),
|
||||
constraint items_default_sales_price_non_negative check (
|
||||
default_sales_price is null or default_sales_price >= 0
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists {schema}.cash_discount_terms (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
discount_percent numeric(7, 4) not null,
|
||||
discount_days integer not null,
|
||||
net_days integer,
|
||||
valid_from date,
|
||||
valid_until date,
|
||||
is_default_customer_term boolean not null default false,
|
||||
is_default_supplier_term boolean not null default false,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint cash_discount_terms_percent_valid check (
|
||||
discount_percent >= 0 and discount_percent <= 100
|
||||
),
|
||||
constraint cash_discount_terms_days_valid check (
|
||||
discount_days >= 0 and (net_days is null or net_days >= discount_days)
|
||||
),
|
||||
constraint cash_discount_terms_valid_range check (
|
||||
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||
)
|
||||
);
|
||||
|
||||
create unique index if not exists idx_cash_discount_terms_default_customer
|
||||
on {schema}.cash_discount_terms (is_default_customer_term)
|
||||
where is_default_customer_term;
|
||||
|
||||
create unique index if not exists idx_cash_discount_terms_default_supplier
|
||||
on {schema}.cash_discount_terms (is_default_supplier_term)
|
||||
where is_default_supplier_term;
|
||||
|
||||
create table if not exists {schema}.customer_price_terms (
|
||||
id uuid primary key,
|
||||
customer_id uuid not null references {schema}.customers(id) on delete cascade,
|
||||
standard_discount_percent numeric(7, 4) not null default 0,
|
||||
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||
valid_from date,
|
||||
valid_until date,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint customer_price_terms_discount_valid check (
|
||||
standard_discount_percent >= 0 and standard_discount_percent <= 100
|
||||
),
|
||||
constraint customer_price_terms_valid_range check (
|
||||
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_customer_price_terms_customer_active
|
||||
on {schema}.customer_price_terms (customer_id, is_active, valid_from, valid_until);
|
||||
|
||||
create table if not exists {schema}.supplier_price_terms (
|
||||
id uuid primary key,
|
||||
supplier_id uuid not null references {schema}.suppliers(id) on delete cascade,
|
||||
standard_discount_percent numeric(7, 4) not null default 0,
|
||||
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||
payment_days integer,
|
||||
valid_from date,
|
||||
valid_until date,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint supplier_price_terms_discount_valid check (
|
||||
standard_discount_percent >= 0 and standard_discount_percent <= 100
|
||||
),
|
||||
constraint supplier_price_terms_payment_days_valid check (
|
||||
payment_days is null or payment_days >= 0
|
||||
),
|
||||
constraint supplier_price_terms_valid_range check (
|
||||
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_supplier_price_terms_supplier_active
|
||||
on {schema}.supplier_price_terms (supplier_id, is_active, valid_from, valid_until);
|
||||
|
||||
create table if not exists {schema}.activities (
|
||||
id uuid primary key,
|
||||
activity_type text not null,
|
||||
title_ciphertext bytea not null,
|
||||
title_nonce bytea not null,
|
||||
title_key_id text not null,
|
||||
body_ciphertext bytea,
|
||||
body_nonce bytea,
|
||||
body_key_id text,
|
||||
status text not null default 'open',
|
||||
priority text not null default 'normal',
|
||||
due_at timestamptz,
|
||||
starts_at timestamptz,
|
||||
ends_at timestamptz,
|
||||
assigned_to_user_id uuid,
|
||||
created_by_user_id uuid,
|
||||
completed_by_user_id uuid,
|
||||
completed_at timestamptz,
|
||||
visibility text not null default 'internal',
|
||||
system_source text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint activities_type_valid check (
|
||||
activity_type in (
|
||||
'email_note',
|
||||
'phone_note',
|
||||
'internal_note',
|
||||
'task',
|
||||
'follow_up',
|
||||
'calendar_event',
|
||||
'system_event',
|
||||
'work_step'
|
||||
)
|
||||
),
|
||||
constraint activities_status_valid check (
|
||||
status in ('open', 'in_progress', 'done', 'cancelled')
|
||||
),
|
||||
constraint activities_priority_valid check (
|
||||
priority in ('low', 'normal', 'high', 'critical')
|
||||
),
|
||||
constraint activities_visibility_valid check (
|
||||
visibility in ('internal', 'organization')
|
||||
),
|
||||
constraint activities_body_encryption_complete check (
|
||||
(
|
||||
body_ciphertext is null
|
||||
and body_nonce is null
|
||||
and body_key_id is null
|
||||
)
|
||||
or (
|
||||
body_ciphertext is not null
|
||||
and body_nonce is not null
|
||||
and body_key_id is not null
|
||||
)
|
||||
),
|
||||
constraint activities_time_range_valid check (
|
||||
ends_at is null or starts_at is null or ends_at >= starts_at
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_activities_status_due
|
||||
on {schema}.activities (status, due_at);
|
||||
|
||||
create index if not exists idx_activities_assigned_status
|
||||
on {schema}.activities (assigned_to_user_id, status);
|
||||
|
||||
create table if not exists {schema}.activity_links (
|
||||
activity_id uuid not null references {schema}.activities(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id uuid not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (activity_id, entity_type, entity_id),
|
||||
constraint activity_links_entity_type_valid check (
|
||||
entity_type in (
|
||||
'customer',
|
||||
'supplier',
|
||||
'contact',
|
||||
'quote',
|
||||
'outgoing_invoice',
|
||||
'incoming_invoice',
|
||||
'item',
|
||||
'document',
|
||||
'import'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_activity_links_entity
|
||||
on {schema}.activity_links (entity_type, entity_id);
|
||||
|
||||
create table if not exists {schema}.outgoing_invoices (
|
||||
id uuid primary key,
|
||||
invoice_number text unique,
|
||||
customer_id uuid not null references {schema}.customers(id),
|
||||
status text not null default 'draft',
|
||||
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||
issued_at date,
|
||||
due_at date,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
finalized_at timestamptz,
|
||||
constraint outgoing_invoices_status_valid check (
|
||||
status in ('draft', 'finalized', 'sent', 'paid', 'cancelled', 'overdue')
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_outgoing_invoices_customer_status
|
||||
on {schema}.outgoing_invoices (customer_id, status);
|
||||
|
||||
create table if not exists {schema}.outgoing_invoice_items (
|
||||
id uuid primary key,
|
||||
invoice_id uuid not null references {schema}.outgoing_invoices(id) on delete cascade,
|
||||
line_number integer not null,
|
||||
item_id uuid not null references {schema}.items(id),
|
||||
description_ciphertext bytea,
|
||||
description_nonce bytea,
|
||||
description_key_id text,
|
||||
quantity numeric(14, 4) not null,
|
||||
unit_price numeric(14, 4) not null,
|
||||
original_unit_price numeric(14, 4),
|
||||
discount_percent numeric(7, 4) not null default 0,
|
||||
price_overridden boolean not null default false,
|
||||
price_override_reason_ciphertext bytea,
|
||||
price_override_reason_nonce bytea,
|
||||
price_override_reason_key_id text,
|
||||
price_overridden_by_user_id uuid,
|
||||
price_overridden_at timestamptz,
|
||||
tax_rate numeric(7, 4) not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (invoice_id, line_number),
|
||||
constraint outgoing_invoice_items_quantity_positive check (quantity > 0),
|
||||
constraint outgoing_invoice_items_unit_price_non_negative check (unit_price >= 0),
|
||||
constraint outgoing_invoice_items_original_price_non_negative check (
|
||||
original_unit_price is null or original_unit_price >= 0
|
||||
),
|
||||
constraint outgoing_invoice_items_discount_valid check (
|
||||
discount_percent >= 0 and discount_percent <= 100
|
||||
),
|
||||
constraint outgoing_invoice_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||
constraint outgoing_invoice_items_description_encryption_complete check (
|
||||
(
|
||||
description_ciphertext is null
|
||||
and description_nonce is null
|
||||
and description_key_id is null
|
||||
)
|
||||
or (
|
||||
description_ciphertext is not null
|
||||
and description_nonce is not null
|
||||
and description_key_id is not null
|
||||
)
|
||||
),
|
||||
constraint outgoing_invoice_items_override_reason_encryption_complete check (
|
||||
(
|
||||
price_override_reason_ciphertext is null
|
||||
and price_override_reason_nonce is null
|
||||
and price_override_reason_key_id is null
|
||||
)
|
||||
or (
|
||||
price_override_reason_ciphertext is not null
|
||||
and price_override_reason_nonce is not null
|
||||
and price_override_reason_key_id is not null
|
||||
)
|
||||
),
|
||||
constraint outgoing_invoice_items_override_complete check (
|
||||
(
|
||||
price_overridden = false
|
||||
and price_overridden_by_user_id is null
|
||||
and price_overridden_at is null
|
||||
)
|
||||
or (
|
||||
price_overridden = true
|
||||
and price_overridden_by_user_id is not null
|
||||
and price_overridden_at is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_outgoing_invoice_items_item
|
||||
on {schema}.outgoing_invoice_items (item_id);
|
||||
|
||||
create table if not exists {schema}.incoming_invoices (
|
||||
id uuid primary key,
|
||||
invoice_number text,
|
||||
supplier_id uuid not null references {schema}.suppliers(id),
|
||||
status text not null default 'draft',
|
||||
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||
invoice_date date,
|
||||
due_at date,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint incoming_invoices_status_valid check (
|
||||
status in ('draft', 'received', 'approved', 'paid', 'cancelled', 'overdue')
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_incoming_invoices_supplier_status
|
||||
on {schema}.incoming_invoices (supplier_id, status);
|
||||
|
||||
create table if not exists {schema}.incoming_invoice_items (
|
||||
id uuid primary key,
|
||||
invoice_id uuid not null references {schema}.incoming_invoices(id) on delete cascade,
|
||||
line_number integer not null,
|
||||
item_id uuid references {schema}.items(id),
|
||||
description_ciphertext bytea,
|
||||
description_nonce bytea,
|
||||
description_key_id text,
|
||||
quantity numeric(14, 4) not null,
|
||||
unit_price numeric(14, 4) not null,
|
||||
tax_rate numeric(7, 4) not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (invoice_id, line_number),
|
||||
constraint incoming_invoice_items_quantity_positive check (quantity > 0),
|
||||
constraint incoming_invoice_items_unit_price_non_negative check (unit_price >= 0),
|
||||
constraint incoming_invoice_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||
constraint incoming_invoice_items_description_encryption_complete check (
|
||||
(
|
||||
description_ciphertext is null
|
||||
and description_nonce is null
|
||||
and description_key_id is null
|
||||
)
|
||||
or (
|
||||
description_ciphertext is not null
|
||||
and description_nonce is not null
|
||||
and description_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
45
backend/company-migrations/0003_customer_details.sql
Normal file
45
backend/company-migrations/0003_customer_details.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Additional encrypted customer fields for organization schemas.
|
||||
|
||||
alter table {schema}.customers
|
||||
add column if not exists details_ciphertext bytea,
|
||||
add column if not exists details_nonce bytea,
|
||||
add column if not exists details_key_id text;
|
||||
|
||||
alter table {schema}.customers
|
||||
drop constraint if exists customers_details_encryption_complete;
|
||||
|
||||
alter table {schema}.customers
|
||||
add constraint customers_details_encryption_complete check (
|
||||
(
|
||||
details_ciphertext is null
|
||||
and details_nonce is null
|
||||
and details_key_id is null
|
||||
)
|
||||
or (
|
||||
details_ciphertext is not null
|
||||
and details_nonce is not null
|
||||
and details_key_id is not null
|
||||
)
|
||||
);
|
||||
|
||||
alter table {schema}.suppliers
|
||||
add column if not exists details_ciphertext bytea,
|
||||
add column if not exists details_nonce bytea,
|
||||
add column if not exists details_key_id text;
|
||||
|
||||
alter table {schema}.suppliers
|
||||
drop constraint if exists suppliers_details_encryption_complete;
|
||||
|
||||
alter table {schema}.suppliers
|
||||
add constraint suppliers_details_encryption_complete check (
|
||||
(
|
||||
details_ciphertext is null
|
||||
and details_nonce is null
|
||||
and details_key_id is null
|
||||
)
|
||||
or (
|
||||
details_ciphertext is not null
|
||||
and details_nonce is not null
|
||||
and details_key_id is not null
|
||||
)
|
||||
);
|
||||
22
backend/company-migrations/0004_item_price_history.sql
Normal file
22
backend/company-migrations/0004_item_price_history.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.item_price_history (
|
||||
id uuid primary key,
|
||||
item_id uuid not null references {schema}.items(id) on delete cascade,
|
||||
purchase_price numeric(14, 4),
|
||||
sales_price numeric(14, 4),
|
||||
source text not null default 'manual',
|
||||
valid_from timestamptz not null default now(),
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint item_price_history_purchase_price_non_negative check (
|
||||
purchase_price is null or purchase_price >= 0
|
||||
),
|
||||
constraint item_price_history_sales_price_non_negative check (
|
||||
sales_price is null or sales_price >= 0
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_item_price_history_item_valid_from
|
||||
on {schema}.item_price_history (item_id, valid_from desc);
|
||||
9
backend/company-migrations/0005_numbered_activities.sql
Normal file
9
backend/company-migrations/0005_numbered_activities.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
alter table {schema}.activities
|
||||
add column if not exists activity_number text;
|
||||
|
||||
create unique index if not exists idx_activities_activity_number
|
||||
on {schema}.activities (activity_number)
|
||||
where activity_number is not null;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
update {schema}.number_ranges
|
||||
set pattern = 'KU{counter}', updated_at = now()
|
||||
where code = 'customers';
|
||||
|
||||
update {schema}.number_ranges
|
||||
set pattern = 'LI{counter}', updated_at = now()
|
||||
where code = 'suppliers';
|
||||
|
||||
update {schema}.number_ranges
|
||||
set pattern = 'AR{counter}', updated_at = now()
|
||||
where code = 'items';
|
||||
|
||||
update {schema}.number_ranges
|
||||
set pattern = 'AK{counter}', updated_at = now()
|
||||
where code = 'activities';
|
||||
|
||||
update {schema}.number_ranges
|
||||
set pattern = 'AR{counter}', updated_at = now()
|
||||
where code = 'outgoing_invoices';
|
||||
82
backend/company-migrations/0007_quotes.sql
Normal file
82
backend/company-migrations/0007_quotes.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.quotes (
|
||||
id uuid primary key,
|
||||
quote_number text not null unique,
|
||||
customer_id uuid not null references {schema}.customers(id),
|
||||
status text not null default 'draft',
|
||||
valid_until date,
|
||||
cash_discount_term_id uuid references {schema}.cash_discount_terms(id),
|
||||
customer_discount_percent numeric(7, 4) not null default 0,
|
||||
notes_ciphertext bytea,
|
||||
notes_nonce bytea,
|
||||
notes_key_id text,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint quotes_status_valid check (
|
||||
status in ('draft', 'sent', 'accepted', 'rejected', 'expired', 'cancelled')
|
||||
),
|
||||
constraint quotes_customer_discount_valid check (
|
||||
customer_discount_percent >= 0 and customer_discount_percent <= 100
|
||||
),
|
||||
constraint quotes_notes_encryption_complete check (
|
||||
(
|
||||
notes_ciphertext is null
|
||||
and notes_nonce is null
|
||||
and notes_key_id is null
|
||||
)
|
||||
or (
|
||||
notes_ciphertext is not null
|
||||
and notes_nonce is not null
|
||||
and notes_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_quotes_customer_status
|
||||
on {schema}.quotes (customer_id, status);
|
||||
|
||||
create table if not exists {schema}.quote_items (
|
||||
id uuid primary key,
|
||||
quote_id uuid not null references {schema}.quotes(id) on delete cascade,
|
||||
line_number integer not null,
|
||||
item_id uuid not null references {schema}.items(id),
|
||||
description_ciphertext bytea,
|
||||
description_nonce bytea,
|
||||
description_key_id text,
|
||||
quantity numeric(14, 4) not null,
|
||||
unit_price numeric(14, 4) not null,
|
||||
original_unit_price numeric(14, 4),
|
||||
discount_percent numeric(7, 4) not null default 0,
|
||||
price_overridden boolean not null default false,
|
||||
tax_rate numeric(7, 4) not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (quote_id, line_number),
|
||||
constraint quote_items_quantity_positive check (quantity > 0),
|
||||
constraint quote_items_unit_price_non_negative check (unit_price >= 0),
|
||||
constraint quote_items_original_price_non_negative check (
|
||||
original_unit_price is null or original_unit_price >= 0
|
||||
),
|
||||
constraint quote_items_discount_valid check (
|
||||
discount_percent >= 0 and discount_percent <= 100
|
||||
),
|
||||
constraint quote_items_tax_rate_non_negative check (tax_rate >= 0),
|
||||
constraint quote_items_description_encryption_complete check (
|
||||
(
|
||||
description_ciphertext is null
|
||||
and description_nonce is null
|
||||
and description_key_id is null
|
||||
)
|
||||
or (
|
||||
description_ciphertext is not null
|
||||
and description_nonce is not null
|
||||
and description_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_quote_items_item
|
||||
on {schema}.quote_items (item_id);
|
||||
19
backend/company-migrations/0008_invoice_links.sql
Normal file
19
backend/company-migrations/0008_invoice_links.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
alter table {schema}.outgoing_invoices
|
||||
add column if not exists source_quote_id uuid references {schema}.quotes(id);
|
||||
|
||||
alter table {schema}.outgoing_invoices
|
||||
add column if not exists customer_discount_percent numeric(7, 4) not null default 0;
|
||||
|
||||
alter table {schema}.outgoing_invoices
|
||||
drop constraint if exists outgoing_invoices_customer_discount_valid;
|
||||
|
||||
alter table {schema}.outgoing_invoices
|
||||
add constraint outgoing_invoices_customer_discount_valid check (
|
||||
customer_discount_percent >= 0 and customer_discount_percent <= 100
|
||||
);
|
||||
|
||||
create index if not exists idx_outgoing_invoices_source_quote
|
||||
on {schema}.outgoing_invoices (source_quote_id);
|
||||
71
backend/company-migrations/0009_price_imports.sql
Normal file
71
backend/company-migrations/0009_price_imports.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.imports (
|
||||
id uuid primary key,
|
||||
import_type text not null,
|
||||
source_name text not null,
|
||||
status text not null default 'previewed',
|
||||
total_rows integer not null default 0,
|
||||
applied_rows integer not null default 0,
|
||||
error_rows integer not null default 0,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
finished_at timestamptz,
|
||||
constraint imports_type_valid check (import_type in ('price_list', 'api_price_sync')),
|
||||
constraint imports_status_valid check (status in ('previewed', 'applied', 'failed'))
|
||||
);
|
||||
|
||||
create table if not exists {schema}.import_mappings (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
delimiter text not null default ';',
|
||||
item_number_column text not null default 'item_number',
|
||||
name_column text not null default 'name',
|
||||
unit_column text not null default 'unit',
|
||||
tax_rate_column text not null default 'tax_rate',
|
||||
purchase_price_column text not null default 'purchase_price',
|
||||
sales_price_column text not null default 'sales_price',
|
||||
is_default boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists idx_import_mappings_default
|
||||
on {schema}.import_mappings (is_default)
|
||||
where is_default;
|
||||
|
||||
create table if not exists {schema}.price_rules (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
source_type text not null default 'import',
|
||||
source_id uuid,
|
||||
markup_percent numeric(7, 4) not null default 0,
|
||||
rounding_mode text not null default 'none',
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint price_rules_source_type_valid check (source_type in ('import', 'api', 'supplier')),
|
||||
constraint price_rules_markup_valid check (markup_percent >= -100 and markup_percent <= 1000),
|
||||
constraint price_rules_rounding_mode_valid check (rounding_mode in ('none', 'cent', 'five_cent', 'ten_cent', 'whole'))
|
||||
);
|
||||
|
||||
create table if not exists {schema}.api_connectors (
|
||||
id uuid primary key,
|
||||
code text not null unique,
|
||||
name text not null,
|
||||
connector_type text not null,
|
||||
config_ciphertext bytea not null,
|
||||
config_nonce bytea not null,
|
||||
config_key_id text not null,
|
||||
is_active boolean not null default true,
|
||||
sync_interval_minutes integer,
|
||||
last_sync_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint api_connectors_interval_valid check (
|
||||
sync_interval_minutes is null or sync_interval_minutes > 0
|
||||
)
|
||||
);
|
||||
153
backend/company-migrations/0010_communications_documents.sql
Normal file
153
backend/company-migrations/0010_communications_documents.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- Template migration for each organization schema.
|
||||
-- Replace {schema} with the real schema name, e.g. company_<organization_id>.
|
||||
|
||||
create table if not exists {schema}.communications (
|
||||
id uuid primary key,
|
||||
communication_type text not null,
|
||||
direction text not null,
|
||||
subject_ciphertext bytea not null,
|
||||
subject_nonce bytea not null,
|
||||
subject_key_id text not null,
|
||||
body_ciphertext bytea,
|
||||
body_nonce bytea,
|
||||
body_key_id text,
|
||||
status text not null default 'open',
|
||||
occurred_at timestamptz,
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint communications_type_valid check (
|
||||
communication_type in ('email', 'phone', 'letter', 'meeting', 'internal_note')
|
||||
),
|
||||
constraint communications_direction_valid check (
|
||||
direction in ('inbound', 'outbound', 'internal')
|
||||
),
|
||||
constraint communications_status_valid check (
|
||||
status in ('open', 'done', 'archived')
|
||||
),
|
||||
constraint communications_body_encryption_complete check (
|
||||
(
|
||||
body_ciphertext is null
|
||||
and body_nonce is null
|
||||
and body_key_id is null
|
||||
)
|
||||
or (
|
||||
body_ciphertext is not null
|
||||
and body_nonce is not null
|
||||
and body_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_communications_type_status
|
||||
on {schema}.communications (communication_type, status, occurred_at desc);
|
||||
|
||||
create table if not exists {schema}.communication_links (
|
||||
communication_id uuid not null references {schema}.communications(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id uuid not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (communication_id, entity_type, entity_id),
|
||||
constraint communication_links_entity_type_valid check (
|
||||
entity_type in (
|
||||
'customer',
|
||||
'supplier',
|
||||
'activity',
|
||||
'quote',
|
||||
'outgoing_invoice',
|
||||
'incoming_invoice',
|
||||
'item',
|
||||
'document'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_communication_links_entity
|
||||
on {schema}.communication_links (entity_type, entity_id);
|
||||
|
||||
create table if not exists {schema}.documents (
|
||||
id uuid primary key,
|
||||
title_ciphertext bytea not null,
|
||||
title_nonce bytea not null,
|
||||
title_key_id text not null,
|
||||
description_ciphertext bytea,
|
||||
description_nonce bytea,
|
||||
description_key_id text,
|
||||
status text not null default 'active',
|
||||
created_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint documents_status_valid check (status in ('active', 'archived', 'deleted')),
|
||||
constraint documents_description_encryption_complete check (
|
||||
(
|
||||
description_ciphertext is null
|
||||
and description_nonce is null
|
||||
and description_key_id is null
|
||||
)
|
||||
or (
|
||||
description_ciphertext is not null
|
||||
and description_nonce is not null
|
||||
and description_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists {schema}.document_versions (
|
||||
id uuid primary key,
|
||||
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||
version_no integer not null,
|
||||
file_name_ciphertext bytea not null,
|
||||
file_name_nonce bytea not null,
|
||||
file_name_key_id text not null,
|
||||
content_type_ciphertext bytea not null,
|
||||
content_type_nonce bytea not null,
|
||||
content_type_key_id text not null,
|
||||
file_size bigint not null,
|
||||
storage_path text not null,
|
||||
checksum_sha256 text not null,
|
||||
uploaded_by_user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (document_id, version_no),
|
||||
constraint document_versions_file_size_valid check (file_size >= 0)
|
||||
);
|
||||
|
||||
create index if not exists idx_document_versions_document_version
|
||||
on {schema}.document_versions (document_id, version_no desc);
|
||||
|
||||
create table if not exists {schema}.document_links (
|
||||
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||
entity_type text not null,
|
||||
entity_id uuid not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (document_id, entity_type, entity_id),
|
||||
constraint document_links_entity_type_valid check (
|
||||
entity_type in (
|
||||
'customer',
|
||||
'supplier',
|
||||
'activity',
|
||||
'communication',
|
||||
'quote',
|
||||
'outgoing_invoice',
|
||||
'incoming_invoice',
|
||||
'item'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_document_links_entity
|
||||
on {schema}.document_links (entity_type, entity_id);
|
||||
|
||||
create table if not exists {schema}.document_audit_log (
|
||||
id uuid primary key,
|
||||
document_id uuid not null references {schema}.documents(id) on delete cascade,
|
||||
version_id uuid references {schema}.document_versions(id) on delete set null,
|
||||
action text not null,
|
||||
user_id uuid,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint document_audit_log_action_valid check (
|
||||
action in ('upload', 'download', 'archive')
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_document_audit_log_document
|
||||
on {schema}.document_audit_log (document_id, created_at desc);
|
||||
9
backend/company-migrations/0011_user_settings.sql
Normal file
9
backend/company-migrations/0011_user_settings.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
create table if not exists {schema}.user_settings (
|
||||
user_id uuid not null,
|
||||
key text not null,
|
||||
value_ciphertext bytea not null,
|
||||
value_nonce bytea not null,
|
||||
value_key_id text not null,
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (user_id, key)
|
||||
);
|
||||
28
backend/company-migrations/0012_item_supplier_prices.sql
Normal file
28
backend/company-migrations/0012_item_supplier_prices.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
alter table {schema}.items
|
||||
add column if not exists manufacturer_code text;
|
||||
|
||||
create table if not exists {schema}.item_supplier_prices (
|
||||
id uuid primary key,
|
||||
item_id uuid not null references {schema}.items(id) on delete cascade,
|
||||
supplier_id uuid not null references {schema}.suppliers(id) on delete cascade,
|
||||
external_item_number text not null,
|
||||
purchase_price numeric(14, 4) not null,
|
||||
currency text not null default 'EUR',
|
||||
is_preferred boolean not null default false,
|
||||
valid_from date,
|
||||
valid_until date,
|
||||
source text not null default 'manual',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint item_supplier_prices_purchase_price_non_negative check (purchase_price >= 0),
|
||||
constraint item_supplier_prices_currency_valid check (char_length(currency) = 3),
|
||||
constraint item_supplier_prices_valid_range check (
|
||||
valid_until is null or valid_from is null or valid_until >= valid_from
|
||||
)
|
||||
);
|
||||
|
||||
create unique index if not exists idx_item_supplier_prices_supplier_external
|
||||
on {schema}.item_supplier_prices (supplier_id, external_item_number);
|
||||
|
||||
create index if not exists idx_item_supplier_prices_item
|
||||
on {schema}.item_supplier_prices (item_id, purchase_price);
|
||||
94
backend/migrations/20260521170000_public_core.sql
Normal file
94
backend/migrations/20260521170000_public_core.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
create table if not exists users (
|
||||
id uuid primary key,
|
||||
email text not null unique,
|
||||
display_name_ciphertext bytea,
|
||||
display_name_nonce bytea,
|
||||
display_name_key_id text,
|
||||
password_hash text,
|
||||
is_active boolean not null default true,
|
||||
must_change_password boolean not null default false,
|
||||
initial_password_expires_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
last_login_at timestamptz,
|
||||
constraint users_email_lowercase check (email = lower(email)),
|
||||
constraint users_display_name_encryption_complete check (
|
||||
(
|
||||
display_name_ciphertext is null
|
||||
and display_name_nonce is null
|
||||
and display_name_key_id is null
|
||||
)
|
||||
or (
|
||||
display_name_ciphertext is not null
|
||||
and display_name_nonce is not null
|
||||
and display_name_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists organizations (
|
||||
id uuid primary key,
|
||||
display_name_ciphertext bytea,
|
||||
display_name_nonce bytea,
|
||||
display_name_key_id text,
|
||||
schema_name text unique,
|
||||
status text not null default 'pending_approval',
|
||||
registration_email text not null,
|
||||
setup_completed_at timestamptz,
|
||||
approved_by_user_id uuid references users(id),
|
||||
approved_at timestamptz,
|
||||
rejected_by_user_id uuid references users(id),
|
||||
rejected_at timestamptz,
|
||||
rejection_reason text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint organizations_status_valid check (
|
||||
status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended')
|
||||
),
|
||||
constraint organizations_registration_email_lowercase check (registration_email = lower(registration_email)),
|
||||
constraint organizations_schema_name_valid check (
|
||||
schema_name is null or schema_name ~ '^company_[a-z0-9_]+$'
|
||||
),
|
||||
constraint organizations_display_name_encryption_complete check (
|
||||
(
|
||||
display_name_ciphertext is null
|
||||
and display_name_nonce is null
|
||||
and display_name_key_id is null
|
||||
)
|
||||
or (
|
||||
display_name_ciphertext is not null
|
||||
and display_name_nonce is not null
|
||||
and display_name_key_id is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists user_organizations (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
organization_id uuid not null references organizations(id) on delete cascade,
|
||||
status text not null default 'pending_invitation',
|
||||
invited_by_user_id uuid references users(id),
|
||||
invited_at timestamptz,
|
||||
accepted_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (user_id, organization_id),
|
||||
constraint user_organizations_status_valid check (
|
||||
status in ('pending_invitation', 'active', 'disabled')
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists organization_domains (
|
||||
id uuid primary key,
|
||||
organization_id uuid not null references organizations(id) on delete cascade,
|
||||
domain text not null unique,
|
||||
is_primary boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint organization_domains_domain_lowercase check (domain = lower(domain))
|
||||
);
|
||||
|
||||
create index if not exists idx_user_organizations_organization_id
|
||||
on user_organizations (organization_id);
|
||||
|
||||
create index if not exists idx_organizations_status
|
||||
on organizations (status);
|
||||
68
backend/migrations/20260521171000_public_auth_sessions.sql
Normal file
68
backend/migrations/20260521171000_public_auth_sessions.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
create table if not exists auth_identities (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
provider text not null,
|
||||
provider_subject text not null,
|
||||
email_at_provider text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (provider, provider_subject)
|
||||
);
|
||||
|
||||
create table if not exists refresh_tokens (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
organization_id uuid references organizations(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz,
|
||||
revoked_reason text,
|
||||
user_agent text,
|
||||
created_ip text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists socket_tokens (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
organization_id uuid not null references organizations(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists session_keys (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
organization_id uuid not null references organizations(id) on delete cascade,
|
||||
key_id text not null unique,
|
||||
wrapped_key bytea,
|
||||
algorithm text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
create table if not exists idempotency_keys (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
organization_id uuid references organizations(id) on delete cascade,
|
||||
key text not null,
|
||||
request_hash text not null,
|
||||
response_status integer,
|
||||
response_body_json jsonb,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (user_id, organization_id, key)
|
||||
);
|
||||
|
||||
create index if not exists idx_refresh_tokens_user_id
|
||||
on refresh_tokens (user_id);
|
||||
|
||||
create index if not exists idx_socket_tokens_user_organization
|
||||
on socket_tokens (user_id, organization_id);
|
||||
|
||||
create index if not exists idx_session_keys_user_organization
|
||||
on session_keys (user_id, organization_id);
|
||||
61
backend/migrations/20260521172000_public_onboarding.sql
Normal file
61
backend/migrations/20260521172000_public_onboarding.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
create table if not exists organization_registration_requests (
|
||||
id uuid primary key,
|
||||
organization_name_ciphertext bytea not null,
|
||||
organization_name_nonce bytea not null,
|
||||
organization_name_key_id text not null,
|
||||
email text not null,
|
||||
status text not null default 'pending_approval',
|
||||
organization_id uuid references organizations(id),
|
||||
requested_at timestamptz not null default now(),
|
||||
decided_by_user_id uuid references users(id),
|
||||
decided_at timestamptz,
|
||||
decision_note text,
|
||||
constraint organization_registration_requests_email_lowercase check (email = lower(email)),
|
||||
constraint organization_registration_requests_status_valid check (
|
||||
status in ('pending_approval', 'approved', 'active', 'rejected', 'suspended')
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists user_invitations (
|
||||
id uuid primary key,
|
||||
organization_id uuid not null references organizations(id) on delete cascade,
|
||||
email text not null,
|
||||
invited_by_user_id uuid not null references users(id),
|
||||
status text not null default 'pending',
|
||||
expires_at timestamptz not null,
|
||||
accepted_at timestamptz,
|
||||
created_user_id uuid references users(id),
|
||||
created_at timestamptz not null default now(),
|
||||
constraint user_invitations_email_lowercase check (email = lower(email)),
|
||||
constraint user_invitations_status_valid check (
|
||||
status in ('pending', 'accepted', 'expired', 'revoked')
|
||||
)
|
||||
);
|
||||
|
||||
create table if not exists email_outbox (
|
||||
id uuid primary key,
|
||||
recipient_email text not null,
|
||||
template text not null,
|
||||
payload_ciphertext bytea not null,
|
||||
payload_nonce bytea not null,
|
||||
payload_key_id text not null,
|
||||
status text not null default 'pending',
|
||||
attempt_count integer not null default 0,
|
||||
last_error text,
|
||||
send_after timestamptz not null default now(),
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint email_outbox_recipient_email_lowercase check (recipient_email = lower(recipient_email)),
|
||||
constraint email_outbox_status_valid check (
|
||||
status in ('pending', 'sending', 'sent', 'failed')
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists idx_organization_registration_requests_status
|
||||
on organization_registration_requests (status, requested_at);
|
||||
|
||||
create index if not exists idx_user_invitations_organization_status
|
||||
on user_invitations (organization_id, status);
|
||||
|
||||
create index if not exists idx_email_outbox_status_send_after
|
||||
on email_outbox (status, send_after);
|
||||
@@ -0,0 +1,9 @@
|
||||
create table if not exists records (
|
||||
id uuid primary key,
|
||||
title text not null,
|
||||
updated_at timestamptz not null
|
||||
);
|
||||
|
||||
insert into records (id, title, updated_at)
|
||||
select '00000000-0000-0000-0000-000000000001'::uuid, 'Erster Datensatz', now()
|
||||
where not exists (select 1 from records);
|
||||
2
backend/migrations/20260521174000_registration_terms.sql
Normal file
2
backend/migrations/20260521174000_registration_terms.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
alter table organization_registration_requests
|
||||
add column if not exists terms_accepted_at timestamptz;
|
||||
23
backend/migrations/20260601190000_security_operations.sql
Normal file
23
backend/migrations/20260601190000_security_operations.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
alter table user_invitations
|
||||
add column if not exists token_hash text,
|
||||
add column if not exists accepted_by_user_id uuid references users(id);
|
||||
|
||||
create unique index if not exists idx_user_invitations_token_hash
|
||||
on user_invitations (token_hash)
|
||||
where token_hash is not null;
|
||||
|
||||
create table if not exists password_reset_tokens (
|
||||
id uuid primary key,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
token_hash text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_password_reset_tokens_user
|
||||
on password_reset_tokens (user_id, expires_at);
|
||||
|
||||
alter table email_outbox
|
||||
add column if not exists subject text,
|
||||
add column if not exists delivered_via text;
|
||||
6903
backend/src/api.rs
Normal file
6903
backend/src/api.rs
Normal file
File diff suppressed because it is too large
Load Diff
54
backend/src/crypto_at_rest.rs
Normal file
54
backend/src/crypto_at_rest.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use companytool_shared_protocol::crypto::SessionKey;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DataCrypto {
|
||||
key: SessionKey,
|
||||
}
|
||||
|
||||
pub struct EncryptedField {
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub nonce: Vec<u8>,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
impl DataCrypto {
|
||||
pub fn from_env() -> Self {
|
||||
let key_id = std::env::var("COMPANYTOOL_DATA_KEY_ID")
|
||||
.unwrap_or_else(|_| "dev-data-key-v1".to_string());
|
||||
let key_base64 = std::env::var("COMPANYTOOL_DATA_KEY_BASE64").unwrap_or_else(|_| {
|
||||
warn!("COMPANYTOOL_DATA_KEY_BASE64 fehlt; verwende nur für Entwicklung geeigneten festen Schlüssel");
|
||||
STANDARD.encode([7_u8; 32])
|
||||
});
|
||||
let key = SessionKey::from_base64(key_id, &key_base64).expect("valid data encryption key");
|
||||
|
||||
Self { key }
|
||||
}
|
||||
|
||||
pub fn encrypt<T: Serialize>(&self, value: &T) -> anyhow::Result<EncryptedField> {
|
||||
let envelope = self.key.encrypt(value)?;
|
||||
Ok(EncryptedField {
|
||||
ciphertext: STANDARD.decode(envelope.ciphertext)?,
|
||||
nonce: STANDARD.decode(envelope.nonce)?,
|
||||
key_id: envelope.key_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt<T: DeserializeOwned>(
|
||||
&self,
|
||||
ciphertext: &[u8],
|
||||
nonce: &[u8],
|
||||
key_id: &str,
|
||||
) -> anyhow::Result<T> {
|
||||
let envelope = companytool_shared_protocol::EncryptedEnvelope {
|
||||
enc: "aes-256-gcm-v1".to_string(),
|
||||
key_id: key_id.to_string(),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
ciphertext: STANDARD.encode(ciphertext),
|
||||
};
|
||||
|
||||
Ok(self.key.decrypt(&envelope)?)
|
||||
}
|
||||
}
|
||||
472
backend/src/main.rs
Normal file
472
backend/src/main.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
mod api;
|
||||
mod crypto_at_rest;
|
||||
mod models;
|
||||
|
||||
use std::{env, net::SocketAddr};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use companytool_shared_protocol::{
|
||||
crypto::SessionKey, ClientMessage, HelloAckMessage, ProtocolErrorMessage, RecordSummary,
|
||||
ServerMessage, WireMessage, PROTOCOL_VERSION,
|
||||
};
|
||||
use crypto_at_rest::DataCrypto;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use sqlx::{PgPool, Row};
|
||||
use tokio::sync::broadcast;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: Option<PgPool>,
|
||||
crypto: DataCrypto,
|
||||
events: broadcast::Sender<ServerMessage>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn db(&self) -> Result<&PgPool, api::ApiError> {
|
||||
self.db
|
||||
.as_ref()
|
||||
.ok_or_else(|| api::ApiError::bad_request("Datenbank ist im Testmodus nicht aktiv"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let communication_test_mode = env::var("COMMUNICATION_TEST_MODE").as_deref() == Ok("1");
|
||||
let bind: SocketAddr = env::var("BACKEND_BIND")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8080".to_string())
|
||||
.parse()
|
||||
.context("BACKEND_BIND ist keine gültige Adresse")?;
|
||||
|
||||
let db = if communication_test_mode {
|
||||
warn!("COMMUNICATION_TEST_MODE aktiv: backend startet ohne datenbank");
|
||||
None
|
||||
} else {
|
||||
let database_url =
|
||||
env::var("DATABASE_URL").context("DATABASE_URL fehlt, siehe .env.example")?;
|
||||
let db = PgPool::connect(&database_url).await?;
|
||||
migrate(&db).await?;
|
||||
Some(db)
|
||||
};
|
||||
|
||||
let (events, _) = broadcast::channel(256);
|
||||
let state = AppState {
|
||||
db,
|
||||
crypto: DataCrypto::from_env(),
|
||||
events,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/ws", get(ws_handler))
|
||||
.route(
|
||||
"/api/v1/dev/bootstrap-local",
|
||||
post(api::dev_bootstrap_local),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/registration/organization",
|
||||
post(api::register_organization),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations",
|
||||
get(api::list_organization_registrations),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations/:id",
|
||||
get(api::get_organization_registration),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations/:id/approve",
|
||||
post(api::approve_organization_registration),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations/:id/reject",
|
||||
post(api::reject_organization_registration),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations/:id/resend-initial-email",
|
||||
post(api::resend_initial_email),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/organization-registrations/:id/retry-provisioning",
|
||||
post(api::retry_provisioning),
|
||||
)
|
||||
.route("/api/v1/auth/login", post(api::login))
|
||||
.route(
|
||||
"/api/v1/auth/change-initial-password",
|
||||
post(api::change_initial_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/auth/request-password-reset",
|
||||
post(api::request_password_reset),
|
||||
)
|
||||
.route("/api/v1/auth/reset-password", post(api::reset_password))
|
||||
.route(
|
||||
"/api/v1/auth/accept-invitation",
|
||||
post(api::accept_invitation),
|
||||
)
|
||||
.route("/api/v1/auth/organizations", get(api::auth_organizations))
|
||||
.route(
|
||||
"/api/v1/auth/select-organization",
|
||||
post(api::select_organization),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/organizations/current/setup",
|
||||
get(api::get_organization_setup).put(api::put_organization_setup),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/users/me/settings/navigation",
|
||||
get(api::get_user_navigation_settings).put(api::put_user_navigation_settings),
|
||||
)
|
||||
.route("/api/v1/number-ranges", get(api::list_number_ranges))
|
||||
.route(
|
||||
"/api/v1/number-ranges/:code/next",
|
||||
axum::routing::post(api::generate_next_number),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/number-ranges/:code",
|
||||
axum::routing::put(api::update_number_range),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/customers",
|
||||
get(api::list_customers).post(api::create_customer),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/customers/:customer_id",
|
||||
axum::routing::put(api::update_customer).delete(api::delete_customer),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/suppliers",
|
||||
get(api::list_suppliers).post(api::create_supplier),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/suppliers/:supplier_id",
|
||||
axum::routing::put(api::update_supplier).delete(api::delete_supplier),
|
||||
)
|
||||
.route("/api/v1/items", get(api::list_items).post(api::create_item))
|
||||
.route(
|
||||
"/api/v1/items/:item_id/prices",
|
||||
get(api::list_item_price_history),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/items/:item_id",
|
||||
axum::routing::put(api::update_item).delete(api::delete_item),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/cash-discount-terms",
|
||||
get(api::list_cash_discount_terms).post(api::create_cash_discount_term),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/cash-discount-terms/:term_id",
|
||||
axum::routing::put(api::update_cash_discount_term)
|
||||
.delete(api::delete_cash_discount_term),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/quotes",
|
||||
get(api::list_quotes).post(api::create_quote),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/quotes/:quote_id",
|
||||
axum::routing::put(api::update_quote).delete(api::delete_quote),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/quotes/:quote_id/convert-to-invoice",
|
||||
post(api::convert_quote_to_outgoing_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/outgoing-invoices",
|
||||
get(api::list_outgoing_invoices).post(api::create_outgoing_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/outgoing-invoices/:invoice_id",
|
||||
axum::routing::put(api::update_outgoing_invoice).delete(api::delete_outgoing_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/outgoing-invoices/:invoice_id/finalize",
|
||||
post(api::finalize_outgoing_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/incoming-invoices",
|
||||
get(api::list_incoming_invoices).post(api::create_incoming_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/incoming-invoices/:invoice_id",
|
||||
axum::routing::put(api::update_incoming_invoice).delete(api::delete_incoming_invoice),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/imports/price-list/preview",
|
||||
post(api::preview_price_list_import),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/imports/price-list/apply",
|
||||
post(api::apply_price_list_import),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/api-connectors",
|
||||
get(api::list_api_connectors).post(api::create_api_connector),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/api-connectors/:connector_id",
|
||||
axum::routing::put(api::update_api_connector).delete(api::delete_api_connector),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/api-connectors/:connector_id/sync",
|
||||
post(api::sync_api_connector),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/price-rules",
|
||||
get(api::list_price_rules).post(api::create_price_rule),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/price-rules/:rule_id",
|
||||
axum::routing::put(api::update_price_rule).delete(api::delete_price_rule),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/activities",
|
||||
get(api::list_activities).post(api::create_activity),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/activities/:activity_id",
|
||||
axum::routing::put(api::update_activity).delete(api::delete_activity),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/communications",
|
||||
get(api::list_communications).post(api::create_communication),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/communications/:communication_id",
|
||||
axum::routing::put(api::update_communication).delete(api::delete_communication),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/documents",
|
||||
get(api::list_documents).post(api::upload_document),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/documents/:document_id/download",
|
||||
get(api::download_document),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/documents/:document_id/audit-log",
|
||||
get(api::list_document_audit_log),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/documents/:document_id",
|
||||
axum::routing::delete(api::delete_document),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/organizations/current/invitations",
|
||||
post(api::invite_user),
|
||||
)
|
||||
.route("/api/v1/organizations/current/users", get(api::list_users))
|
||||
.route(
|
||||
"/api/v1/organizations/current/users/:user_id/roles",
|
||||
patch(api::update_user_roles),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/organizations/current/users/:user_id/disable",
|
||||
post(api::disable_user),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||
info!("backend lauscht auf http://{bind}");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut events = state.events.subscribe();
|
||||
let mut session_key: Option<SessionKey> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(Ok(message)) = receiver.next() => {
|
||||
if let Message::Text(text) = message {
|
||||
if let Some(new_session_key) = handle_client_wire_message(&text, &state, &mut sender, session_key.as_ref()).await {
|
||||
if let Ok(snapshot) = load_snapshot(state.db.as_ref()).await {
|
||||
if send_encrypted_server_message(
|
||||
&mut sender,
|
||||
&new_session_key,
|
||||
ServerMessage::Snapshot { records: snapshot },
|
||||
).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
session_key = Some(new_session_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(event) = events.recv() => {
|
||||
if let Some(active_session_key) = session_key.as_ref() {
|
||||
if let Err(error) = send_encrypted_server_message(&mut sender, active_session_key, event).await {
|
||||
warn!(%error, "server message konnte nicht gesendet werden");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client_wire_message(
|
||||
text: &str,
|
||||
state: &AppState,
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
session_key: Option<&SessionKey>,
|
||||
) -> Option<SessionKey> {
|
||||
match serde_json::from_str::<WireMessage>(text) {
|
||||
Ok(WireMessage::Hello(hello)) => {
|
||||
if hello.protocol_version != PROTOCOL_VERSION {
|
||||
let _ = send_wire_error(sender, "nicht unterstützte Protokollversion").await;
|
||||
return None;
|
||||
}
|
||||
|
||||
match SessionKey::from_base64(hello.key_id.clone(), &hello.session_key) {
|
||||
Ok(new_session_key) => {
|
||||
let ack = WireMessage::HelloAck(HelloAckMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
key_id: hello.key_id,
|
||||
});
|
||||
if send_wire_message(sender, ack).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(new_session_key)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ =
|
||||
send_wire_error(sender, &format!("ungültiger Session-Key: {error}")).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WireMessage::Encrypted(envelope)) => {
|
||||
let Some(active_session_key) = session_key else {
|
||||
let _ = send_wire_error(sender, "verschlüsselte Nachricht vor hello").await;
|
||||
return None;
|
||||
};
|
||||
|
||||
match active_session_key.decrypt::<ClientMessage>(&envelope) {
|
||||
Ok(message) => handle_client_message(message, state).await,
|
||||
Err(error) => {
|
||||
let _ = send_wire_error(
|
||||
sender,
|
||||
&format!("Nachricht konnte nicht entschlüsselt werden: {error}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Ok(WireMessage::HelloAck(_) | WireMessage::Error(_)) => None,
|
||||
Err(error) => {
|
||||
let _ = send_wire_error(sender, &format!("ungültige Wire-Nachricht: {error}")).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client_message(message: ClientMessage, state: &AppState) {
|
||||
match message {
|
||||
ClientMessage::Ping => {
|
||||
let _ = state.events.send(ServerMessage::Pong);
|
||||
}
|
||||
ClientMessage::Subscribe { topic } => {
|
||||
info!(%topic, "client hat topic abonniert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_encrypted_server_message(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
session_key: &SessionKey,
|
||||
message: ServerMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
let envelope = session_key.encrypt(&message)?;
|
||||
send_wire_message(sender, WireMessage::Encrypted(envelope)).await
|
||||
}
|
||||
|
||||
async fn send_wire_error(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
send_wire_message(
|
||||
sender,
|
||||
WireMessage::Error(ProtocolErrorMessage {
|
||||
message: message.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_wire_message(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: WireMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
let text = serde_json::to_string(&message)?;
|
||||
sender.send(Message::Text(text)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate(db: &PgPool) -> anyhow::Result<()> {
|
||||
sqlx::migrate!("./migrations").run(db).await?;
|
||||
api::sync_all_company_schemas(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_snapshot(db: Option<&PgPool>) -> anyhow::Result<Vec<RecordSummary>> {
|
||||
let Some(db) = db else {
|
||||
return Ok(vec![RecordSummary {
|
||||
id: Uuid::nil(),
|
||||
title: "Kommunikationstest-Datensatz".to_string(),
|
||||
updated_at: Utc::now(),
|
||||
}]);
|
||||
};
|
||||
|
||||
let rows = sqlx::query("select id, title, updated_at from records order by updated_at desc")
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RecordSummary {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
210
backend/src/models.rs
Normal file
210
backend/src/models.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrganizationStatus {
|
||||
PendingApproval,
|
||||
Approved,
|
||||
Active,
|
||||
Rejected,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserOrganizationStatus {
|
||||
PendingInvitation,
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RegistrationStatus {
|
||||
PendingApproval,
|
||||
Approved,
|
||||
Active,
|
||||
Rejected,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvitationStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
Expired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EmailOutboxStatus {
|
||||
Pending,
|
||||
Sending,
|
||||
Sent,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub display_name_ciphertext: Option<Vec<u8>>,
|
||||
pub display_name_nonce: Option<Vec<u8>>,
|
||||
pub display_name_key_id: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub must_change_password: bool,
|
||||
pub initial_password_expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Organization {
|
||||
pub id: Uuid,
|
||||
pub display_name_ciphertext: Option<Vec<u8>>,
|
||||
pub display_name_nonce: Option<Vec<u8>>,
|
||||
pub display_name_key_id: Option<String>,
|
||||
pub schema_name: Option<String>,
|
||||
pub status: OrganizationStatus,
|
||||
pub registration_email: String,
|
||||
pub setup_completed_at: Option<DateTime<Utc>>,
|
||||
pub approved_by_user_id: Option<Uuid>,
|
||||
pub approved_at: Option<DateTime<Utc>>,
|
||||
pub rejected_by_user_id: Option<Uuid>,
|
||||
pub rejected_at: Option<DateTime<Utc>>,
|
||||
pub rejection_reason: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct UserOrganization {
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub status: UserOrganizationStatus,
|
||||
pub invited_by_user_id: Option<Uuid>,
|
||||
pub invited_at: Option<DateTime<Utc>>,
|
||||
pub accepted_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct AuthIdentity {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub provider: String,
|
||||
pub provider_subject: String,
|
||||
pub email_at_provider: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct RefreshToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Option<Uuid>,
|
||||
pub token_hash: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub revoked_reason: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_ip: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SocketToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub token_hash: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub used_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SessionKeyRecord {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub key_id: String,
|
||||
pub wrapped_key: Option<Vec<u8>>,
|
||||
pub algorithm: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct IdempotencyKey {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub organization_id: Option<Uuid>,
|
||||
pub key: String,
|
||||
pub request_hash: String,
|
||||
pub response_status: Option<i32>,
|
||||
pub response_body_json: Option<serde_json::Value>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct OrganizationRegistrationRequest {
|
||||
pub id: Uuid,
|
||||
pub organization_name_ciphertext: Vec<u8>,
|
||||
pub organization_name_nonce: Vec<u8>,
|
||||
pub organization_name_key_id: String,
|
||||
pub email: String,
|
||||
pub status: RegistrationStatus,
|
||||
pub organization_id: Option<Uuid>,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub decided_by_user_id: Option<Uuid>,
|
||||
pub decided_at: Option<DateTime<Utc>>,
|
||||
pub decision_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct UserInvitation {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub email: String,
|
||||
pub invited_by_user_id: Uuid,
|
||||
pub status: InvitationStatus,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub accepted_at: Option<DateTime<Utc>>,
|
||||
pub created_user_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct EmailOutboxItem {
|
||||
pub id: Uuid,
|
||||
pub recipient_email: String,
|
||||
pub template: String,
|
||||
pub payload_ciphertext: Vec<u8>,
|
||||
pub payload_nonce: Vec<u8>,
|
||||
pub payload_key_id: String,
|
||||
pub status: EmailOutboxStatus,
|
||||
pub attempt_count: i32,
|
||||
pub last_error: Option<String>,
|
||||
pub send_after: DateTime<Utc>,
|
||||
pub sent_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
Reference in New Issue
Block a user