summaryrefslogtreecommitdiff
path: root/src/cmd_org.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 20:11:19 +0200
commit8351fa7752bb8341ef405a37ff7c73142715e981 (patch)
treedde603d313f4f26934f2ce20a22dfd9b69324cf0 /src/cmd_org.c
parentbeafdbe0d64340387d619c38554976410129f05b (diff)
downloadbokf-8351fa7752bb8341ef405a37ff7c73142715e981.tar.gz
bokf-8351fa7752bb8341ef405a37ff7c73142715e981.zip
commands: split the command table by domain
Diffstat (limited to 'src/cmd_org.c')
-rw-r--r--src/cmd_org.c753
1 files changed, 753 insertions, 0 deletions
diff --git a/src/cmd_org.c b/src/cmd_org.c
new file mode 100644
index 0000000..4776491
--- /dev/null
+++ b/src/cmd_org.c
@@ -0,0 +1,753 @@
+#include "commands.h"
+#include "cmd_util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "audit.h"
+#include "config.h"
+#include "db.h"
+#include "seed.h"
+#include "util.h"
+
+/* ------------------------------------------------------------------ */
+/* orgs */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr", sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 3)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "address",
+ sq(sqlite3_column_text(st, 4)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code",
+ sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 6)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "country",
+ sq(sqlite3_column_text(st, 7)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 8)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "phone", sq(sqlite3_column_text(st, 9)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month",
+ sqlite3_column_int(st, 10));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period",
+ sq(sqlite3_column_text(st, 11)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework",
+ sq(sqlite3_column_text(st, 12)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at",
+ sq(sqlite3_column_text(st, 13)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "description",
+ sq(sqlite3_column_text(st, 14)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "shares",
+ sqlite3_column_int64(st, 15));
+ return o;
+}
+
+#define ORG_COLUMNS \
+ "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \
+ "phone,fiscal_year_start_month,moms_period,framework,created_at," \
+ "description,shares"
+
+static yyjson_mut_val *h_org_create(struct req *r)
+{
+ if (!g_cfg.allow_org_create && !r->is_admin)
+ return fail(r, "FORBIDDEN", "org creation is disabled");
+ const char *name = arg_str(r->args, "name");
+ if (!name || !*name)
+ return fail(r, "INVALID_ARGS", "name is required");
+ const char *org_nr = arg_str(r->args, "org_nr");
+ int64_t fy_month = 1;
+ arg_int(r->args, "fiscal_year_start_month", &fy_month);
+ if (fy_month < 1 || fy_month > 12)
+ return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12");
+ const char *moms = arg_str(r->args, "moms_period");
+ if (!moms)
+ moms = "month";
+ if (strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 &&
+ strcmp(moms, "year") != 0)
+ return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year");
+ const char *framework = arg_str(r->args, "framework");
+ if (!framework)
+ framework = "K2";
+ if (strcmp(framework, "K2") != 0 && strcmp(framework, "K3") != 0)
+ return fail(r, "INVALID_ARGS", "framework must be K2 or K3");
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0)
+ return fail(r, "DB_BUSY", "could not start transaction");
+
+ sqlite3_stmt *st = NULL;
+ int rc = sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO orgs(name,org_nr,fiscal_year_start_month,moms_period,"
+ "framework,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT);
+ if (org_nr)
+ sqlite3_bind_text(st, 2, org_nr, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 2);
+ sqlite3_bind_int64(st, 3, fy_month);
+ sqlite3_bind_text(st, 4, moms, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 5, framework, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, r->sess->user_id);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t org_id = db_last_id(r->db);
+
+ rc = sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO memberships(org_id,user_id,role,created_at)"
+ " VALUES(?1,?2,'owner',?3)",
+ -1, &st, NULL);
+ if (rc != SQLITE_OK) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, r->sess->user_id);
+ sqlite3_bind_text(st, 3, ts, -1, SQLITE_TRANSIENT);
+ rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ }
+ int64_t fy_id = 0;
+ char *seed_err = NULL;
+ if (seed_org(r->db, org_id, framework, (int)fy_month, &fy_id,
+ &seed_err) != 0) {
+ db_exec(r->db, "ROLLBACK", NULL);
+ yyjson_mut_val *res =
+ fail(r, "INTERNAL", seed_err ? seed_err : "seeding failed");
+ free(seed_err);
+ return res;
+ }
+ if (db_exec(r->db, "COMMIT", NULL) != 0)
+ return fail(r, "INTERNAL", "commit failed");
+
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, org_id, r->sess->user_id, r->sess->token_id,
+ "org.create", reqjson, "OK", NULL);
+ free(reqjson);
+ r->sess->active_org = org_id;
+
+ st = NULL;
+ if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, org_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = org_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "INTERNAL", "created org not found");
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_id", fy_id);
+ return o;
+}
+
+static yyjson_mut_val *h_org_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT o.id,o.name,o.org_nr,m.role FROM memberships m"
+ " JOIN orgs o ON o.id=m.org_id WHERE m.user_id=?1 ORDER BY o.id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->sess->user_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "org_nr",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role",
+ sq(sqlite3_column_text(st, 3)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static yyjson_mut_val *h_org_get(struct req *r)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS " FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = org_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "org not found");
+ return o;
+}
+
+static yyjson_mut_val *h_org_update(struct req *r)
+{
+ const char *name = arg_str(r->args, "name");
+ const char *org_nr = arg_str(r->args, "org_nr");
+ const char *vat_nr = arg_str(r->args, "vat_nr");
+ const char *address = arg_str(r->args, "address");
+ const char *postal = arg_str(r->args, "postal_code");
+ const char *city = arg_str(r->args, "city");
+ const char *country = arg_str(r->args, "country");
+ const char *email = arg_str(r->args, "email");
+ const char *phone = arg_str(r->args, "phone");
+ const char *moms = arg_str(r->args, "moms_period");
+ const char *framework = arg_str(r->args, "framework");
+ const char *description = arg_str(r->args, "description");
+ int64_t fy_month = 0;
+ arg_int(r->args, "fiscal_year_start_month", &fy_month);
+ int64_t shares = -1;
+ arg_int(r->args, "shares", &shares);
+ if (!name && !org_nr && !vat_nr && !address && !postal && !city &&
+ !country && !email && !phone && !moms && !framework && fy_month == 0 &&
+ !description && shares < 0)
+ return fail(r, "INVALID_ARGS", "nothing to update");
+ if (name && !*name)
+ return fail(r, "INVALID_ARGS", "name cannot be empty");
+ if (fy_month && (fy_month < 1 || fy_month > 12))
+ return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12");
+ if (shares < -1)
+ return fail(r, "INVALID_ARGS", "shares must be >= 0");
+ if (moms && strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 &&
+ strcmp(moms, "year") != 0)
+ return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year");
+ if (framework && strcmp(framework, "K2") != 0 &&
+ strcmp(framework, "K3") != 0)
+ return fail(r, "INVALID_ARGS", "framework must be K2 or K3");
+
+ static const char *const keys[10] = { "name", "org_nr", "vat_nr",
+ "address", "postal_code", "city",
+ "country", "email", "phone",
+ "description" };
+ const char *texts[10] = { name, org_nr, vat_nr, address, postal,
+ city, country, email, phone, description };
+
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ for (int i = 0; i < 10; i++)
+ if (texts[i])
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, keys[i], texts[i]);
+ if (fy_month)
+ yyjson_mut_obj_add_int(r->rdoc, o, "fiscal_year_start_month",
+ fy_month);
+ if (moms)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", moms);
+ if (framework)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", framework);
+ if (shares >= 0)
+ yyjson_mut_obj_add_int(r->rdoc, o, "shares", shares);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE orgs SET"
+ " name=COALESCE(?2,name), org_nr=COALESCE(?3,org_nr),"
+ " vat_nr=COALESCE(?4,vat_nr), address=COALESCE(?5,address),"
+ " postal_code=COALESCE(?6,postal_code), city=COALESCE(?7,city),"
+ " country=COALESCE(?8,country), email=COALESCE(?9,email),"
+ " phone=COALESCE(?10,phone), description=COALESCE(?11,"
+ " description),"
+ " fiscal_year_start_month=CASE WHEN ?12=0"
+ " THEN fiscal_year_start_month ELSE ?12 END,"
+ " moms_period=COALESCE(?13,moms_period),"
+ " framework=COALESCE(?14,framework),"
+ " shares=CASE WHEN ?15<0 THEN shares ELSE ?15 END"
+ " WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ for (int i = 0; i < 10; i++) {
+ if (texts[i])
+ sqlite3_bind_text(st, i + 2, texts[i], -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, i + 2);
+ }
+ sqlite3_bind_int64(st, 12, fy_month);
+ if (moms)
+ sqlite3_bind_text(st, 13, moms, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 13);
+ if (framework)
+ sqlite3_bind_text(st, 14, framework, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 14);
+ sqlite3_bind_int64(st, 15, shares);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "org.update", reqjson, "OK", NULL);
+ free(reqjson);
+
+ st = NULL;
+ if (sqlite3_prepare_v2(r->db, "SELECT " ORG_COLUMNS
+ " FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = org_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "org not found");
+ return o;
+}
+
+static yyjson_mut_val *h_org_member_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT u.id,u.username,u.display_name,m.role FROM memberships m"
+ " JOIN users u ON u.id=m.user_id WHERE m.org_id=?1 ORDER BY u.id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items);
+ yyjson_mut_obj_add_int(r->rdoc, o, "user_id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "username",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "display_name",
+ sq(sqlite3_column_text(st, 2)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "role",
+ sq(sqlite3_column_text(st, 3)));
+ }
+ sqlite3_finalize(st);
+ yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, out, "items", items);
+ return out;
+}
+
+static int64_t username_to_id(sqlite3 *db, const char *username)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t id = -1;
+ if (sqlite3_prepare_v2(db, "SELECT id FROM users WHERE username=?1", -1,
+ &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_text(st, 1, username, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return id;
+}
+
+static int64_t owner_count(sqlite3 *db, int64_t org_id)
+{
+ sqlite3_stmt *st = NULL;
+ int64_t n = -1;
+ if (sqlite3_prepare_v2(
+ db, "SELECT count(*) FROM memberships WHERE org_id=?1 AND role='owner'",
+ -1, &st, NULL) != SQLITE_OK)
+ return -1;
+ sqlite3_bind_int64(st, 1, org_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ n = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ return n;
+}
+
+static int valid_role(const char *role)
+{
+ return role && (strcmp(role, "owner") == 0 ||
+ strcmp(role, "bookkeeper") == 0 ||
+ strcmp(role, "viewer") == 0);
+}
+
+static yyjson_mut_val *h_org_member_add(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ const char *role = arg_str(r->args, "role");
+ if (!username || !valid_role(role))
+ return fail(r, "INVALID_ARGS", "username and a valid role are required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO memberships(org_id,user_id,role,created_at)"
+ " VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, uid);
+ sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "CONFLICT", "user is already a member");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.add", reqjson,
+ "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_org_member_set_role(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ const char *role = arg_str(r->args, "role");
+ if (!username || !valid_role(role))
+ return fail(r, "INVALID_ARGS", "username and a valid role are required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char *current = db_membership_role(r->db, r->org_id, uid);
+ if (!current)
+ return fail(r, "NOT_FOUND", "user is not a member");
+ if (strcmp(current, "owner") == 0 && strcmp(role, "owner") != 0 &&
+ owner_count(r->db, r->org_id) <= 1) {
+ free(current);
+ return fail(r, "CONFLICT", "cannot demote the last owner");
+ }
+ free(current);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE memberships SET role=?1 WHERE org_id=?2 AND user_id=?3",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_text(st, 1, role, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 2, r->org_id);
+ sqlite3_bind_int64(st, 3, uid);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.set_role",
+ reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+static yyjson_mut_val *h_org_member_remove(struct req *r)
+{
+ const char *username = arg_str(r->args, "username");
+ if (!username)
+ return fail(r, "INVALID_ARGS", "username is required");
+ int64_t uid = username_to_id(r->db, username);
+ if (uid < 0)
+ return fail(r, "NOT_FOUND", "user not found");
+ char *current = db_membership_role(r->db, r->org_id, uid);
+ if (!current)
+ return fail(r, "NOT_FOUND", "user is not a member");
+ if (strcmp(current, "owner") == 0 && owner_count(r->db, r->org_id) <= 1) {
+ free(current);
+ return fail(r, "CONFLICT", "cannot remove the last owner");
+ }
+ free(current);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db, "DELETE FROM memberships WHERE org_id=?1 AND user_id=?2", -1,
+ &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, uid);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, 0, "member.remove",
+ reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+/* ------------------------------------------------------------------ */
+/* board members (årsredovisning signatures) */
+/* ------------------------------------------------------------------ */
+
+static yyjson_mut_val *board_json(struct req *r, sqlite3_stmt *st)
+{
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name",
+ sq(sqlite3_column_text(st, 1)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "title",
+ sq(sqlite3_column_text(st, 2)));
+ return o;
+}
+
+static yyjson_mut_val *h_board_list(struct req *r)
+{
+ yyjson_mut_val *items = yyjson_mut_arr(r->rdoc);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT id,name,title FROM board_members"
+ " WHERE org_id=?1 ORDER BY id",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ while (sqlite3_step(st) == SQLITE_ROW)
+ yyjson_mut_arr_add_val(items, board_json(r, st));
+ sqlite3_finalize(st);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_val(r->rdoc, o, "items", items);
+ return o;
+}
+
+static yyjson_mut_val *board_get(struct req *r, int64_t id)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT id,name,title FROM board_members"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = board_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "board member not found");
+ return o;
+}
+
+static yyjson_mut_val *h_board_add(struct req *r)
+{
+ const char *name = arg_str(r->args, "name");
+ const char *title = arg_str(r->args, "title");
+ if (!name || !*name)
+ return fail(r, "INVALID_ARGS", "name is required");
+ if (!title || !*title)
+ title = "Styrelseledamot";
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title);
+ return o;
+ }
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "INSERT INTO board_members(org_id,name,title,"
+ "created_at) VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ int64_t id = db_last_id(r->db);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "board.add", reqjson, "OK", NULL);
+ free(reqjson);
+ return board_get(r, id);
+}
+
+static yyjson_mut_val *h_board_update(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ const char *name = arg_str(r->args, "name");
+ const char *title = arg_str(r->args, "title");
+ if (!name && !title)
+ return fail(r, "INVALID_ARGS", "nothing to update");
+ if (name && !*name)
+ return fail(r, "INVALID_ARGS", "name cannot be empty");
+ if (title && !*title)
+ return fail(r, "INVALID_ARGS", "title cannot be empty");
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ if (name)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name);
+ if (title)
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title);
+ return o;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "UPDATE board_members SET name=COALESCE(?3,name),"
+ " title=COALESCE(?4,title)"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (name)
+ sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 3);
+ if (title)
+ sqlite3_bind_text(st, 4, title, -1, SQLITE_TRANSIENT);
+ else
+ sqlite3_bind_null(st, 4);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "board member not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "board.update", reqjson, "OK", NULL);
+ free(reqjson);
+ return board_get(r, id);
+}
+
+static yyjson_mut_val *h_board_remove(struct req *r)
+{
+ int64_t id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ return o;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "DELETE FROM board_members"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "board member not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "board.remove", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
+
+static const struct cmd_arg args_org_create[] = {
+ { "name", ARG_STR, 1, NULL, NULL, "Org name" },
+ { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" },
+ { "fiscal_year_start_month", ARG_INT, 0, "1", NULL,
+ "Fiscal year start month 1-12" },
+ { "moms_period", ARG_ENUM, 0, "month", "month,quarter,year",
+ "VAT reporting period" },
+ { "framework", ARG_ENUM, 0, "K2", "K2,K3", "Accounting framework" },
+};
+
+static const struct cmd_arg args_org_update[] = {
+ { "name", ARG_STR, 0, NULL, NULL, "Org name" },
+ { "org_nr", ARG_STR, 0, NULL, NULL, "Swedish org number" },
+ { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" },
+ { "address", ARG_STR, 0, NULL, NULL, "Street address" },
+ { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" },
+ { "city", ARG_STR, 0, NULL, NULL, "City" },
+ { "country", ARG_STR, 0, NULL, NULL, "Country" },
+ { "email", ARG_STR, 0, NULL, NULL, "E-mail" },
+ { "phone", ARG_STR, 0, NULL, NULL, "Phone" },
+ { "moms_period", ARG_ENUM, 0, NULL, "month,quarter,year",
+ "VAT reporting period" },
+ { "framework", ARG_ENUM, 0, NULL, "K2,K3", "Accounting framework" },
+ { "description", ARG_STR, 0, NULL, NULL, "Free-text description" },
+ { "fiscal_year_start_month", ARG_INT, 0, NULL, NULL,
+ "Fiscal year start month 1-12" },
+ { "shares", ARG_INT, 0, NULL, NULL, "Number of shares" },
+};
+
+static const struct cmd_arg args_member_role[] = {
+ { "username", ARG_STR, 1, NULL, NULL, "Username" },
+ { "role", ARG_ENUM, 1, NULL, "owner,bookkeeper,viewer",
+ "Membership role" },
+};
+
+static const struct cmd_arg args_member_username[] = {
+ { "username", ARG_STR, 1, NULL, NULL, "Username" },
+};
+
+static const struct cmd_arg args_board_add[] = {
+ { "name", ARG_STR, 1, NULL, NULL, "Board member name" },
+ { "title", ARG_STR, 0, "Styrelseledamot", NULL, "Role on the board" },
+};
+
+static const struct cmd_arg args_board_update[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Board member id" },
+ { "name", ARG_STR, 0, NULL, NULL, "Board member name" },
+ { "title", ARG_STR, 0, NULL, NULL, "Role on the board" },
+};
+
+static const struct cmd_arg args_board_remove[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Board member id" },
+};
+
+const struct command g_cmd_org[] = {
+ { "org.create", "Create an org and become its owner", PERM_READ, 0, 1, 1,
+ h_org_create, CMD_ARGS(args_org_create) },
+ { "org.list", "List orgs for the current user", PERM_READ, 0, 0, 0,
+ h_org_list, NULL, 0 },
+ { "org.get", "Get org details", PERM_READ, 1, 0, 0, h_org_get, NULL, 0 },
+ { "org.update", "Update org details (owner)", PERM_OWNER, 1, 1, 1,
+ h_org_update, CMD_ARGS(args_org_update) },
+ { "org.member_list", "List org members", PERM_READ, 1, 0, 0,
+ h_org_member_list, NULL, 0 },
+ { "org.member_add", "Add a member to the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_add, CMD_ARGS(args_member_role) },
+ { "org.member_set_role", "Change a member's role", PERM_OWNER, 1, 1, 1,
+ h_org_member_set_role, CMD_ARGS(args_member_role) },
+ { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1,
+ h_org_member_remove, CMD_ARGS(args_member_username) },
+ { "board.list", "List board members", PERM_READ, 1, 0, 0, h_board_list,
+ NULL, 0 },
+ { "board.add", "Add a board member", PERM_OWNER, 1, 1, 1, h_board_add,
+ CMD_ARGS(args_board_add) },
+ { "board.update", "Update a board member", PERM_OWNER, 1, 1, 1,
+ h_board_update, CMD_ARGS(args_board_update) },
+ { "board.remove", "Remove a board member", PERM_OWNER, 1, 1, 1,
+ h_board_remove, CMD_ARGS(args_board_remove) },
+};
+
+const struct cmd_table g_cmd_table_org = {
+ g_cmd_org, sizeof g_cmd_org / sizeof g_cmd_org[0]
+};