From 8351fa7752bb8341ef405a37ff7c73142715e981 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 20:11:19 +0200 Subject: commands: split the command table by domain --- src/cmd_templates.c | 539 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 src/cmd_templates.c (limited to 'src/cmd_templates.c') diff --git a/src/cmd_templates.c b/src/cmd_templates.c new file mode 100644 index 0000000..a134d96 --- /dev/null +++ b/src/cmd_templates.c @@ -0,0 +1,539 @@ +#include "commands.h" +#include "cmd_util.h" + +#include +#include +#include + +#include "audit.h" +#include "db.h" +#include "formula.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* voucher templates (konteringsmallar) */ +/* ------------------------------------------------------------------ */ + +struct tpl_row_in { + int64_t account_id; + char account[16]; + char formula[256]; + char desc[128]; +}; +static int parse_template_rows(struct req *r, yyjson_val *rowsv, + struct tpl_row_in **out, size_t *out_n) +{ + *out = NULL; + *out_n = 0; + if (!rowsv || !yyjson_is_arr(rowsv)) + return fail(r, "INVALID_ARGS", "rows must be an array") ? -1 : -1; + size_t n = yyjson_arr_size(rowsv); + if (n == 0 || n > 100) + return fail(r, "INVALID_ARGS", + "a template needs between 1 and 100 rows") + ? -1 + : -1; + struct tpl_row_in *rows = xcalloc(n, sizeof *rows); + size_t k = 0; + yyjson_arr_iter it = yyjson_arr_iter_with(rowsv); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it))) { + if (!yyjson_is_obj(item)) { + free(rows); + fail(r, "INVALID_ARGS", "each row must be an object"); + return -1; + } + yyjson_val *av = yyjson_obj_get(item, "account"); + yyjson_val *fv = yyjson_obj_get(item, "formula"); + if (!av || !yyjson_is_str(av) || !fv || !yyjson_is_str(fv)) { + free(rows); + failf(r, "INVALID_ARGS", + "row %zu: account and formula are required", k + 1); + return -1; + } + const char *account = yyjson_get_str(av); + const char *formula = yyjson_get_str(fv); + if (!formula_valid(formula)) { + free(rows); + failf(r, "INVALID_ARGS", "row %zu: ogiltig formel '%s'", k + 1, + formula); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id FROM accounts WHERE org_id=?1" + " AND number=?2", + -1, &st, NULL) != SQLITE_OK) { + free(rows); + return fail(r, "INTERNAL", "database error") ? -1 : -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, account, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + free(rows); + failf(r, "ACCOUNT_NOT_FOUND", "row %zu: account %s does not exist", + k + 1, account); + return -1; + } + rows[k].account_id = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + snprintf(rows[k].account, sizeof rows[k].account, "%s", account); + snprintf(rows[k].formula, sizeof rows[k].formula, "%s", formula); + yyjson_val *dv = yyjson_obj_get(item, "description"); + if (dv && yyjson_is_str(dv)) + snprintf(rows[k].desc, sizeof rows[k].desc, "%s", + yyjson_get_str(dv)); + k++; + } + *out = rows; + *out_n = k; + return 0; +} + +static yyjson_mut_val *tpl_rows_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "line_no", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "account", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "formula", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 4))); + return o; +} + +static yyjson_mut_val *h_template_list(struct req *r) +{ + int active_only = 0; + arg_bool(r->args, "active_only", &active_only); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + const char *sql = + active_only + ? "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 AND t.active=1" + " ORDER BY t.name" + : "SELECT t.id,t.name,t.series,t.description,t.active," + "(SELECT count(*) FROM voucher_template_rows tr" + " WHERE tr.org_id=t.org_id AND tr.template_id=t.id)" + " FROM voucher_templates t WHERE t.org_id=?1 ORDER BY t.name"; + if (sqlite3_prepare_v2(r->db, sql, -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, "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, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + yyjson_mut_obj_add_int(r->rdoc, o, "row_count", + sqlite3_column_int64(st, 5)); + } + 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_template_get(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND id=?2" + : "SELECT id,name,series,description,active" + " FROM voucher_templates WHERE org_id=?1 AND name=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "template not found"); + } + int64_t tpl = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 3))); + yyjson_mut_obj_add_bool(r->rdoc, o, "active", + sqlite3_column_int(st, 4) != 0); + sqlite3_finalize(st); + + yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc); + if (sqlite3_prepare_v2( + r->db, + "SELECT tr.line_no,a.number,a.name,tr.formula," + "COALESCE(tr.description,'') FROM voucher_template_rows tr" + " JOIN accounts a ON a.org_id=tr.org_id AND a.id=tr.account_id" + " WHERE tr.org_id=?1 AND tr.template_id=?2 ORDER BY tr.line_no", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(rows, tpl_rows_json(r, st)); + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", rows); + return o; +} + +static yyjson_mut_val *h_template_create(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + char *series_owned = NULL; + if (!series || !*series) { + series_owned = db_setting(r->db, r->org_id, "default_series"); + series = series_owned && *series_owned ? series_owned : "A"; + } + if (!description) + description = ""; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (parse_template_rows( + r, r->args ? yyjson_obj_get(r->args, "rows") : NULL, &rows, + &nrows) != 0) + return NULL; + + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + yyjson_mut_val *arr = yyjson_mut_arr(r->rdoc); + for (size_t i = 0; i < nrows; i++) { + yyjson_mut_val *ro = yyjson_mut_arr_add_obj(r->rdoc, arr); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "account", rows[i].account); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "formula", rows[i].formula); + yyjson_mut_obj_add_strcpy(r->rdoc, ro, "description", rows[i].desc); + } + yyjson_mut_obj_add_val(r->rdoc, o, "rows", arr); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + free(rows); + free(series_owned); + return o; + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + free(series_owned); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "INSERT INTO voucher_templates(org_id,name,series," + "description,created_at) VALUES(?1,?2,?3,?4,?5)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + 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, series, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + if ((rc & 0xff) == SQLITE_CONSTRAINT) + return fail(r, "CONFLICT", "a template with that name exists"); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + int64_t tpl = db_last_id(r->db); + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id,line_no," + "account_id,formula,description) VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, tpl); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + free(rows); + 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, r->org_id, r->sess->user_id, r->sess->token_id, + "template.create", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", tpl); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "series", series); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", description); + free(series_owned); + return o; +} + +static yyjson_mut_val *h_template_update(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name_arg = arg_str(r->args, "name"); + if (id <= 0 && !name_arg) + return fail(r, "INVALID_ARGS", "id or name is required"); + struct tpl_head head; + char *err = NULL; + if (load_template(r->db, r->org_id, id, name_arg, &head, &err) != 0) { + yyjson_mut_val *res = + fail(r, "NOT_FOUND", err ? err : "template not found"); + free(err); + return res; + } + const char *new_name = arg_str(r->args, "name"); + const char *series = arg_str(r->args, "series"); + const char *description = arg_str(r->args, "description"); + int active = -1; + arg_bool(r->args, "active", &active); + yyjson_val *rowsv = r->args ? yyjson_obj_get(r->args, "rows") : NULL; + struct tpl_row_in *rows = NULL; + size_t nrows = 0; + if (rowsv) { + if (parse_template_rows(r, rowsv, &rows, &nrows) != 0) + return NULL; + } + if (!new_name && !series && !description && active < 0 && !rowsv) { + free(rows); + return fail(r, "INVALID_ARGS", "nothing to update"); + } + + if (r->dry_run) { + free(rows); + return h_template_get(r); + } + + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) { + free(rows); + return fail(r, "DB_BUSY", "could not start transaction"); + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "UPDATE voucher_templates SET" + " name=COALESCE(?2,name), series=COALESCE(?3,series)," + " description=COALESCE(?4,description)," + " active=CASE WHEN ?5<0 THEN active ELSE ?5 END, updated_at=?6" + " WHERE org_id=?1 AND id=?7", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + if (new_name) + sqlite3_bind_text(st, 2, new_name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 2); + if (series) + sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (description) + sqlite3_bind_text(st, 4, description, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + sqlite3_bind_int(st, 5, active); + sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, head.id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + if (rowsv) { + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM voucher_template_rows WHERE org_id=?1" + " AND template_id=?2", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + for (size_t i = 0; i < nrows; i++) { + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_template_rows(org_id,template_id," + "line_no,account_id,formula,description)" + " VALUES(?1,?2,?3,?4,?5,?6)", + -1, &st, NULL) != SQLITE_OK) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, head.id); + sqlite3_bind_int64(st, 3, (int64_t)i + 1); + sqlite3_bind_int64(st, 4, rows[i].account_id); + sqlite3_bind_text(st, 5, rows[i].formula, -1, SQLITE_TRANSIENT); + if (rows[i].desc[0]) + sqlite3_bind_text(st, 6, rows[i].desc, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 6); + rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + db_exec(r->db, "ROLLBACK", NULL); + free(rows); + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + } + } + free(rows); + 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, r->org_id, r->sess->user_id, r->sess->token_id, + "template.update", reqjson, "OK", NULL); + free(reqjson); + return h_template_get(r); +} + +static yyjson_mut_val *h_template_archive(struct req *r) +{ + int64_t id = 0; + arg_int(r->args, "id", &id); + const char *name = arg_str(r->args, "name"); + if (id <= 0 && !name) + return fail(r, "INVALID_ARGS", "id or name is required"); + sqlite3_stmt *st = NULL; + const char *sql = + id > 0 ? "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND id=?2" + : "UPDATE voucher_templates SET active=0 WHERE org_id=?1" + " AND name=?2"; + if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + if (id > 0) + sqlite3_bind_int64(st, 2, id); + else + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + 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", "template not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "template.archive", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + + +static const struct cmd_arg args_template_list[] = { + { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active templates" }, +}; + +static const struct cmd_arg args_template_get[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, "Template name" }, +}; + +static const struct cmd_arg args_template_create[] = { + { "name", ARG_STR, 1, NULL, NULL, "Template name" }, + { "series", ARG_STR, 0, NULL, NULL, + "Number series; defaults to settings.default_series" }, + { "description", ARG_STR, 0, NULL, NULL, + "Voucher text; {x} is replaced with the amount" }, + { "rows", ARG_JSON, 1, NULL, NULL, + "Array of {account,formula,description?}" }, +}; + +static const struct cmd_arg args_template_update[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, + "Template name, or the new name when id is given" }, + { "series", ARG_STR, 0, NULL, NULL, "Number series" }, + { "description", ARG_STR, 0, NULL, NULL, "Voucher text" }, + { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, + { "rows", ARG_JSON, 0, NULL, NULL, + "Replacement rows, {account,formula,description?}" }, +}; + +static const struct cmd_arg args_template_archive[] = { + { "id", ARG_INT, 0, NULL, NULL, "Template id" }, + { "name", ARG_STR, 0, NULL, NULL, "Template name" }, +}; + +const struct command g_cmd_templates[] = { + { "template.list", "List voucher templates", PERM_READ, 1, 0, 0, + h_template_list, CMD_ARGS(args_template_list) }, + { "template.get", "Get a voucher template with its rows", PERM_READ, 1, 0, + 0, h_template_get, CMD_ARGS(args_template_get) }, + { "template.create", "Create a voucher template", PERM_WRITE, 1, 1, 1, + h_template_create, CMD_ARGS(args_template_create) }, + { "template.update", "Update a voucher template", PERM_WRITE, 1, 1, 1, + h_template_update, CMD_ARGS(args_template_update) }, + { "template.archive", "Archive a voucher template", PERM_WRITE, 1, 1, 1, + h_template_archive, CMD_ARGS(args_template_archive) }, +}; + +const struct cmd_table g_cmd_table_templates = { + g_cmd_templates, sizeof g_cmd_templates / sizeof g_cmd_templates[0] +}; -- cgit v1.3