feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6f0f4d1eea
commit
fdb5f6f891
@@ -0,0 +1,43 @@
|
||||
-- Fix the SIE import statement timeout for real this time.
|
||||
--
|
||||
-- 20260629160100 (replace_sie_import / undo_sie_import) and 20260721144311
|
||||
-- (import_sie_journal_entries) raised statement_timeout to 290s via
|
||||
-- ALTER FUNCTION ... SET. That is a no-op for the enclosing call: Postgres
|
||||
-- arms the statement_timeout timer when the top-level statement STARTS,
|
||||
-- using the role-level value in effect at that moment (authenticated = 8s),
|
||||
-- and a function-scoped SET applied mid-statement never re-arms the timer.
|
||||
-- Proven empirically 2026-08-04: a probe function declaring
|
||||
-- SET statement_timeout = '10s' around a 3s sleep was still cancelled by a
|
||||
-- 1s session timeout. Large SIE imports therefore still fail at 8s with
|
||||
-- "canceling statement due to statement timeout".
|
||||
--
|
||||
-- Working mechanism: a PostgREST pre-request hook. PostgREST invokes
|
||||
-- pgrst.db_pre_request as its OWN statement inside the request transaction,
|
||||
-- before the main query, so a transaction-local set_config here changes the
|
||||
-- value the main statement's timer is armed with. Scoped by request path to
|
||||
-- the three SIE RPCs; every other request keeps the role default (8s).
|
||||
-- 290s stays just under the calling routes' maxDuration = 300 ceiling
|
||||
-- (/api/import/sie/execute, /api/import/sie/[id]/replace and undo,
|
||||
-- /api/extensions/ext/[...path], /api/v1/.../imports/sie).
|
||||
--
|
||||
-- The function runs on EVERY PostgREST request: it must be cheap and must
|
||||
-- never throw (an error here fails all API requests). current_setting with
|
||||
-- missing_ok handles direct connections where request.path is absent.
|
||||
|
||||
create or replace function public.pre_request_statement_timeout()
|
||||
returns void
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if coalesce(current_setting('request.path', true), '')
|
||||
~ '/rpc/(import_sie_journal_entries|replace_sie_import|undo_sie_import)$'
|
||||
then
|
||||
perform set_config('statement_timeout', '290s', true);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
alter role authenticator set pgrst.db_pre_request = 'public.pre_request_statement_timeout';
|
||||
|
||||
notify pgrst, 'reload config';
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,225 @@
|
||||
-- Migration: teams.kind + deterministic personal-team attachment
|
||||
--
|
||||
-- White-label foundation slice (WL-08 resolution, dev_docs/white-label/):
|
||||
-- teams get a kind ('personal' | 'byra') so byrå teams (named, multi-member,
|
||||
-- brand-carrying) are distinguishable from the silent one-per-user "Personal"
|
||||
-- shells. Byrå teams are created ops-assisted; every existing row becomes
|
||||
-- 'personal' via the column default, and ops flips real byrå teams via the
|
||||
-- service role.
|
||||
--
|
||||
-- Four changes:
|
||||
-- 1. teams.kind column with CHECK ('personal' | 'byra'), default 'personal'
|
||||
-- 2. ensure_user_team() now selects the user's PERSONAL team
|
||||
-- deterministically (the old bare LIMIT 1 with no ORDER BY could hand a
|
||||
-- consultant's new private company to the byrå team)
|
||||
-- 3. AFTER UPDATE OF role trigger on team_members re-syncs team-sourced
|
||||
-- company_members roles (previously a demoted consultant kept admin in
|
||||
-- every client book until they left the team)
|
||||
-- 4. Team rename path: explicit owner/admin UPDATE policy on teams, plus a
|
||||
-- BEFORE UPDATE guard that makes kind changes ops-only
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. teams.kind
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.teams
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'personal';
|
||||
|
||||
ALTER TABLE public.teams
|
||||
ADD CONSTRAINT teams_kind_check CHECK (kind IN ('personal', 'byra'));
|
||||
|
||||
COMMENT ON COLUMN public.teams.kind IS
|
||||
'personal = the silent one-per-user shell every user gets; byra = a named, '
|
||||
'multi-member accounting-firm team (invitable, brand-carrying, cockpit). '
|
||||
'Ops-managed: only the service role may change it after creation.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Deterministic ensure_user_team()
|
||||
-- =============================================================================
|
||||
-- Replaces the definition from 20260415000000_schema_sync.sql. The old body
|
||||
-- picked the user's first team_members row with a bare LIMIT 1 and no ORDER
|
||||
-- BY, so under multi-team membership (own personal team + byrå team) a new
|
||||
-- personal-flow company could arbitrarily attach to the byrå team, granting
|
||||
-- the whole byrå admin access to the user's private books. Companies created
|
||||
-- through the normal flow must always attach to the PERSONAL team; cockpit
|
||||
-- flows pass the byrå team id explicitly (WL-08).
|
||||
-- SECURITY DEFINER + search_path kept exactly as the original.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ensure_user_team()
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_team_id uuid;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
-- Deterministic: only ever the user's personal team. ORDER BY is a
|
||||
-- tie-break for the (unexpected) case of multiple personal teams.
|
||||
SELECT tm.team_id INTO v_team_id
|
||||
FROM public.team_members tm
|
||||
JOIN public.teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = v_user_id
|
||||
AND t.kind = 'personal'
|
||||
ORDER BY t.created_at, t.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_team_id IS NOT NULL THEN
|
||||
RETURN v_team_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.teams (name, created_by, kind)
|
||||
VALUES ('Personal', v_user_id, 'personal')
|
||||
RETURNING id INTO v_team_id;
|
||||
|
||||
INSERT INTO public.team_members (team_id, user_id, role)
|
||||
VALUES (v_team_id, v_user_id, 'owner');
|
||||
|
||||
RETURN v_team_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Role re-sync on team_members role UPDATE
|
||||
-- =============================================================================
|
||||
-- The INSERT sync (20260331010000, sync_team_member_to_companies) maps team
|
||||
-- owner/admin to company admin and everything else to member, but nothing
|
||||
-- re-synced on a role UPDATE: promoting a member did not grant admin, and
|
||||
-- (worse) demoting an admin left them admin in every client company. This
|
||||
-- trigger applies the same mapping to existing team-sourced rows. Rows with
|
||||
-- source = 'direct' are never touched.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.resync_team_member_role_to_companies()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_role text;
|
||||
BEGIN
|
||||
-- Same mapping as sync_team_member_to_companies: team owner/admin becomes
|
||||
-- company admin, else member. Company 'owner' stays reserved for whoever
|
||||
-- created that specific company, so this can never mint a company owner.
|
||||
v_company_role := CASE
|
||||
WHEN NEW.role IN ('owner', 'admin') THEN 'admin'
|
||||
ELSE 'member'
|
||||
END;
|
||||
|
||||
UPDATE public.company_members cm
|
||||
SET role = v_company_role
|
||||
WHERE cm.user_id = NEW.user_id
|
||||
AND cm.source = 'team'
|
||||
AND cm.role IS DISTINCT FROM v_company_role
|
||||
AND cm.company_id IN (
|
||||
SELECT c.id
|
||||
FROM public.companies c
|
||||
WHERE c.team_id = NEW.team_id
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS team_member_sync_role_update ON public.team_members;
|
||||
|
||||
CREATE TRIGGER team_member_sync_role_update
|
||||
AFTER UPDATE OF role ON public.team_members
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.role IS DISTINCT FROM NEW.role)
|
||||
EXECUTE FUNCTION public.resync_team_member_role_to_companies();
|
||||
|
||||
-- The company_members BEFORE UPDATE guard (20260422120000) blocks role
|
||||
-- changes unless the caller is a company owner. The re-sync cascade above is
|
||||
-- driven by an authorized team_members write (team admins, per team_members
|
||||
-- RLS), typically by a caller who is only company *admin* in the client
|
||||
-- companies, so the guard must let trigger-cascaded writes through.
|
||||
-- pg_trigger_depth() > 1 identifies exactly those: a direct UPDATE of
|
||||
-- company_members runs this guard at depth 1; a write issued from inside
|
||||
-- another trigger (the re-sync) runs it at depth 2. Users cannot create
|
||||
-- triggers, and the team-to-company mapping never produces 'owner', so the
|
||||
-- passthrough cannot be used to mint or demote company owners.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_company_member_role_transitions()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
caller_role text;
|
||||
BEGIN
|
||||
-- Service role, SECURITY DEFINER cascades, and direct SQL have no auth context.
|
||||
IF auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Trigger-cascaded writes come from our own sync triggers
|
||||
-- (team_member_sync_role_update), not from a direct user statement.
|
||||
IF pg_trigger_depth() > 1 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Nothing to enforce if the role field isn't changing.
|
||||
IF NEW.role IS NOT DISTINCT FROM OLD.role THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
caller_role := public.user_role_in_company(OLD.company_id);
|
||||
|
||||
IF caller_role IS DISTINCT FROM 'owner' THEN
|
||||
RAISE EXCEPTION
|
||||
'Only owners can change member roles (your role: %)',
|
||||
COALESCE(caller_role, 'none');
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Team rename path
|
||||
-- =============================================================================
|
||||
-- Recreate the UPDATE policy so the rename grant (team owner/admin) is
|
||||
-- explicit next to the kind guard. Behavior is identical to 20260422120000:
|
||||
-- user_is_team_admin() already includes the created_by fallback internally.
|
||||
|
||||
DROP POLICY IF EXISTS "teams_update" ON public.teams;
|
||||
|
||||
CREATE POLICY "teams_update" ON public.teams
|
||||
FOR UPDATE
|
||||
USING (public.user_is_team_admin(id))
|
||||
WITH CHECK (public.user_is_team_admin(id));
|
||||
|
||||
-- Rename is allowed; kind changes are ops-only. Follows the codebase guard
|
||||
-- convention (enforce_company_member_role_transitions): auth.uid() IS NULL
|
||||
-- means service role, SECURITY DEFINER cascade, or direct SQL, all trusted
|
||||
-- ops paths; any user session is rejected.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_team_kind_ops_only()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.kind IS DISTINCT FROM OLD.kind AND auth.uid() IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'teams.kind is ops-managed; rename is allowed but kind changes require the service role'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_team_kind_ops_only ON public.teams;
|
||||
|
||||
CREATE TRIGGER enforce_team_kind_ops_only
|
||||
BEFORE UPDATE ON public.teams
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.enforce_team_kind_ops_only();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Migration: brands table
|
||||
--
|
||||
-- White-label foundation slice (WL-01/WL-02/WL-12 resolutions,
|
||||
-- dev_docs/white-label/): a brand is a byrå team's white-label identity.
|
||||
-- One brand per team (unique team_id), one mutable domain per brand (unique
|
||||
-- domain), row presence = live. Brand rows are ops-managed via the service
|
||||
-- role in v1: no INSERT/UPDATE/DELETE policies on purpose. Host resolution
|
||||
-- (lib/branding/resolve.ts) uses the cookieless service client and bypasses
|
||||
-- RLS by design, because anonymous visitors on a brand domain need the brand
|
||||
-- before login. The single SELECT policy lets members of the owning team read
|
||||
-- their own brand (e.g. a future cockpit surface).
|
||||
--
|
||||
-- Color CHECKs mirror the invoice branding pattern
|
||||
-- (20260526120200_invoice_branding.sql): a regex gate so invalid values never
|
||||
-- reach the renderer. chrome_color is an explicit ops override; NULL means
|
||||
-- the chrome tone is derived from brand_color at runtime (WL-02 model A).
|
||||
|
||||
CREATE TABLE public.brands (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id uuid NOT NULL UNIQUE REFERENCES public.teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- Lowercase hostname only: no scheme, no slash, no port, no uppercase.
|
||||
domain text NOT NULL UNIQUE,
|
||||
|
||||
app_name text NOT NULL,
|
||||
logo_url text,
|
||||
brand_color text NOT NULL,
|
||||
chrome_color text,
|
||||
font_key text NOT NULL DEFAULT 'default',
|
||||
|
||||
-- Email identity (WL-01: the brand carries email sender identity; no
|
||||
-- verified sender domain means the "Partner via Accounted" fallback,
|
||||
-- email never blocks go-live).
|
||||
support_email text NOT NULL,
|
||||
auth_email_from text,
|
||||
sender_domain text,
|
||||
resend_domain_id text,
|
||||
sender_domain_status text NOT NULL DEFAULT 'unverified',
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT brands_domain_format
|
||||
CHECK (domain ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$'),
|
||||
CONSTRAINT brands_brand_color_format
|
||||
CHECK (brand_color ~ '^#[0-9A-Fa-f]{6}$'),
|
||||
CONSTRAINT brands_chrome_color_format
|
||||
CHECK (chrome_color IS NULL OR chrome_color ~ '^#[0-9A-Fa-f]{6}$'),
|
||||
CONSTRAINT brands_sender_domain_status_check
|
||||
CHECK (sender_domain_status IN ('unverified', 'pending', 'verified', 'failed'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.brands IS
|
||||
'A brand is a byrå team''s white-label identity: the domain the app is '
|
||||
'served on, the app name, logo, brand color (chrome tone derived unless '
|
||||
'chrome_color overrides it), font pair from the curated menu, and the email '
|
||||
'sender identity. Exactly one brand per team (unique team_id) and one '
|
||||
'mutable domain per brand (unique domain). Row presence means live: there '
|
||||
'is no active flag; preview happens by pointing domain at a controlled '
|
||||
'subdomain, go-live is one UPDATE, and deleting the row reverts the team''s '
|
||||
'companies to the canonical Accounted appearance. Rows are ops-managed via '
|
||||
'the service role in v1; team members have read-only visibility.';
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.brands ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Members of the owning team may read their brand. No write policies: brand
|
||||
-- rows are created/updated/deleted by ops through the service role in v1.
|
||||
CREATE POLICY "brands_select" ON public.brands
|
||||
FOR SELECT USING (team_id IN (SELECT public.user_team_ids()));
|
||||
|
||||
-- =============================================================================
|
||||
-- Triggers
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TRIGGER brands_updated_at
|
||||
BEFORE UPDATE ON public.brands
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Migration: no 30-day trial for byrå-team companies
|
||||
--
|
||||
-- White-label billing slice (WL-10 resolution, dev_docs/white-label/):
|
||||
-- companies created under a byrå team (teams.kind = 'byra') are already
|
||||
-- entitled via the team-scoped partner grant, so the company-scoped 30-day
|
||||
-- trial is pure noise there: trial-expiry emails would hit byrå clients with
|
||||
-- the wrong message. Personal-team and teamless companies keep the trial
|
||||
-- exactly as shipped in 20260818170000 (the full seven-key PAID set).
|
||||
--
|
||||
-- Replaces ONLY the trigger function body (seed_trial_capability_grants);
|
||||
-- the trigger itself (trg_seed_trial_capability_grants, AFTER INSERT ON
|
||||
-- companies) is untouched. team_id is populated in the same INSERT on every
|
||||
-- creation path (create_company_with_owner p_team_id / direct insert), so
|
||||
-- reading NEW.team_id here is race-free.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_trial_capability_grants()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Byrå-team companies are covered by the team's agreement (WL-10):
|
||||
-- no company-scoped trial, so no trial-expiry noise toward byrå clients.
|
||||
IF NEW.team_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM public.teams t
|
||||
WHERE t.id = NEW.team_id
|
||||
AND t.kind = 'byra'
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Full PAID set as of 20260818170000; keep this VALUES list in step with
|
||||
-- lib/entitlements/keys.ts PAID_CAPABILITIES whenever a key is added.
|
||||
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
|
||||
SELECT NEW.id, k.key, 'trial', NEW.created_at + interval '30 days'
|
||||
FROM (VALUES
|
||||
('ai'),
|
||||
('bank_sync'),
|
||||
('skatteverket'),
|
||||
('email_send'),
|
||||
('stripe_payments'),
|
||||
('woocommerce_sync'),
|
||||
('shopify_sync')
|
||||
) AS k(key)
|
||||
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Migration: byrå client company creation is admin-gated (WL-15)
|
||||
--
|
||||
-- Cockpit slice D3 (dev_docs/white-label/tickets/WL-15): creating a client
|
||||
-- company under a byrå team is a commercial act (+1 on the byrå's monthly
|
||||
-- invoice), so only team OWNERS and ADMINS may do it. Plain members keep
|
||||
-- read/work access to client companies via the sync triggers but cannot
|
||||
-- create new ones.
|
||||
--
|
||||
-- The gate must live in the database: create_company_with_owner is a
|
||||
-- SECURITY DEFINER RPC granted to `authenticated`, so any byrå member could
|
||||
-- otherwise call it directly through PostgREST with the byrå p_team_id and
|
||||
-- bypass every application-level check.
|
||||
--
|
||||
-- Behavior for personal teams is byte-identical to
|
||||
-- 20260519180000_enforce_team_membership_in_create_company.sql: any member
|
||||
-- of a personal team may attach a company to it (in practice the sole owner;
|
||||
-- ensure_user_team creates one personal team per user). Only teams with
|
||||
-- kind = 'byra' (20260826130100) get the stricter role check.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_company_with_owner(
|
||||
p_name text,
|
||||
p_entity_type text,
|
||||
p_set_active boolean DEFAULT true,
|
||||
p_team_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_company_id uuid;
|
||||
v_team_kind text;
|
||||
v_team_role text;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
|
||||
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
|
||||
END IF;
|
||||
|
||||
-- Authorize p_team_id before any write. SECURITY DEFINER bypasses RLS, so
|
||||
-- we must verify membership ourselves; without this any authenticated user
|
||||
-- could attach a company to an arbitrary team (20260519180000). On byrå
|
||||
-- teams the bar is higher: WL-15 locks client company creation to team
|
||||
-- owner/admin because every created company is +1 on the byrå's invoice.
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
SELECT tm.role, t.kind
|
||||
INTO v_team_role, v_team_kind
|
||||
FROM public.team_members tm
|
||||
JOIN public.teams t ON t.id = tm.team_id
|
||||
WHERE tm.team_id = p_team_id
|
||||
AND tm.user_id = v_user_id;
|
||||
|
||||
IF v_team_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not a member of team %', p_team_id
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
|
||||
IF v_team_kind = 'byra' AND v_team_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only byrå team owners and admins can create client companies'
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.companies (name, entity_type, created_by, team_id)
|
||||
VALUES (p_name, p_entity_type, v_user_id, p_team_id)
|
||||
RETURNING id INTO v_company_id;
|
||||
|
||||
INSERT INTO public.company_members (company_id, user_id, role)
|
||||
VALUES (v_company_id, v_user_id, 'owner');
|
||||
|
||||
-- Seed default 1930 SEK cash account so reconciliation routes work before
|
||||
-- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__
|
||||
-- sentinel in skattekonto-booking resolves on day one.
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
VALUES (
|
||||
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
IF p_set_active THEN
|
||||
INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES (v_user_id, v_company_id)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
END IF;
|
||||
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
|
||||
END IF;
|
||||
|
||||
RETURN v_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean, uuid) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Brands: optional dedicated tab icon (WL follow-up, founder ask 2026-08-05).
|
||||
--
|
||||
-- The root layout uses brands.logo_url as rel="icon" on branded hosts, but
|
||||
-- byra logos are wide lockups that squash badly at 16px. favicon_url holds a
|
||||
-- square mark; the layout prefers it over logo_url for the tab icon and
|
||||
-- falls back to logo_url when unset. Ops-managed like the rest of brands
|
||||
-- (no write RLS; byra self-service edits only the logo via its API route).
|
||||
|
||||
alter table public.brands
|
||||
add column if not exists favicon_url text;
|
||||
|
||||
comment on column public.brands.favicon_url is
|
||||
'Square tab icon URL; preferred over logo_url for rel="icon" on branded hosts.';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Migration: byrå admin gate for create_company_for_user (WL-15)
|
||||
--
|
||||
-- 20260826130400 added the byrå owner/admin gate to create_company_with_owner,
|
||||
-- but main's newer service-role variant create_company_for_user (20260825120000,
|
||||
-- used by POST /api/v1/companies and the MCP gnubok_create_company tool) still
|
||||
-- authorizes p_team_id with a membership-only EXISTS. Any plain byrå team
|
||||
-- member could therefore bind a company to the byrå team through the API/MCP
|
||||
-- path, which is a commercial act: every created client company is +1 on the
|
||||
-- byrå's monthly invoice, and the byrå trial-suppression trigger then kills
|
||||
-- the company's trial grants.
|
||||
--
|
||||
-- This replaces the function with the exact gate from 20260826130400: when the
|
||||
-- target team has kind = 'byra', the owner (p_user_id, the API key's user)
|
||||
-- must hold team_members.role owner/admin. Personal-team behavior is
|
||||
-- unchanged: any member may attach. Everything else (validation, inserts,
|
||||
-- cash-account seed, preference upsert, team sync, grants) is byte-identical
|
||||
-- to 20260825120000.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_company_for_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_entity_type text,
|
||||
p_team_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_id uuid;
|
||||
v_team_kind text;
|
||||
v_team_role text;
|
||||
BEGIN
|
||||
IF p_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_user_id is required';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
|
||||
RAISE EXCEPTION 'Unknown user %', p_user_id
|
||||
USING ERRCODE = '23503'; -- foreign_key_violation
|
||||
END IF;
|
||||
|
||||
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
|
||||
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
|
||||
END IF;
|
||||
|
||||
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name is required';
|
||||
END IF;
|
||||
|
||||
-- Same authorization as create_company_with_owner (20260826130400), against
|
||||
-- the explicit owner: SECURITY DEFINER bypasses RLS, so team membership is
|
||||
-- checked here. On byrå teams the bar is higher: WL-15 locks client company
|
||||
-- creation to team owner/admin because every created company is +1 on the
|
||||
-- byrå's invoice.
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
SELECT tm.role, t.kind
|
||||
INTO v_team_role, v_team_kind
|
||||
FROM public.team_members tm
|
||||
JOIN public.teams t ON t.id = tm.team_id
|
||||
WHERE tm.team_id = p_team_id
|
||||
AND tm.user_id = p_user_id;
|
||||
|
||||
IF v_team_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not a member of team %', p_team_id
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
|
||||
IF v_team_kind = 'byra' AND v_team_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only byrå team owners and admins can create client companies'
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.companies (name, entity_type, created_by, team_id)
|
||||
VALUES (btrim(p_name), p_entity_type, p_user_id, p_team_id)
|
||||
RETURNING id INTO v_company_id;
|
||||
|
||||
INSERT INTO public.company_members (company_id, user_id, role)
|
||||
VALUES (v_company_id, p_user_id, 'owner');
|
||||
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
VALUES (
|
||||
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES (p_user_id, v_company_id)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
|
||||
IF p_team_id IS NOT NULL THEN
|
||||
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
|
||||
END IF;
|
||||
|
||||
RETURN v_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Service role only. PostgREST exposes functions to every role by default
|
||||
-- (PUBLIC grant), so revoke first, then grant the one role that may call it.
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM anon;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) FROM authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.create_company_for_user(uuid, text, text, uuid) TO service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user