#include "commands.h" #include "cmd_util.h" #include #include #include #include "audit.h" #include "db.h" #include "util.h" /* ------------------------------------------------------------------ */ /* customer register */ /* ------------------------------------------------------------------ */ #define CUSTOMER_COLUMNS \ "id,name,address,postal_code,city,country,vat_nr,email,your_ref," \ "payment_days,notes,active,created_at,COALESCE(updated_at,'')" static yyjson_mut_val *customer_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, "address", sq(sqlite3_column_text(st, 2))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", sq(sqlite3_column_text(st, 3))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", sq(sqlite3_column_text(st, 4))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", sq(sqlite3_column_text(st, 6))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", sq(sqlite3_column_text(st, 7))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", sq(sqlite3_column_text(st, 8))); yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", sqlite3_column_int64(st, 9)); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", sq(sqlite3_column_text(st, 10))); yyjson_mut_obj_add_bool(r->rdoc, o, "active", sqlite3_column_int(st, 11) != 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 12))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "updated_at", sq(sqlite3_column_text(st, 13))); return o; } struct customer_input { const char *name; const char *address; const char *postal_code; const char *city; const char *country; const char *vat_nr; const char *email; const char *your_ref; const char *notes; int64_t payment_days; int have_payment; int active; int have_active; }; static void customer_input_read(struct req *r, struct customer_input *in) { memset(in, 0, sizeof *in); in->name = arg_str(r->args, "name"); in->address = arg_str(r->args, "address"); in->postal_code = arg_str(r->args, "postal_code"); in->city = arg_str(r->args, "city"); in->country = arg_str(r->args, "country"); in->vat_nr = arg_str(r->args, "vat_nr"); in->email = arg_str(r->args, "email"); in->your_ref = arg_str(r->args, "your_ref"); in->notes = arg_str(r->args, "notes"); in->have_payment = arg_int(r->args, "payment_days", &in->payment_days); in->have_active = arg_bool(r->args, "active", &in->active); } static int customer_input_validate(struct req *r, const struct customer_input *in, int is_create) { if (is_create && (!in->name || !*in->name)) { fail(r, "INVALID_ARGS", "name is required"); return -1; } if (in->name && !*in->name) { fail(r, "INVALID_ARGS", "name cannot be empty"); return -1; } if (in->name && strlen(in->name) > 200) { fail(r, "INVALID_ARGS", "name is too long"); return -1; } static const char *const names[] = { "address", "postal_code", "city", "country", "vat_nr", "email", "your_ref", "notes", }; static const size_t maxlen[] = { 500, 32, 120, 64, 64, 254, 120, 2000 }; const char *values[] = { in->address, in->postal_code, in->city, in->country, in->vat_nr, in->email, in->your_ref, in->notes }; for (size_t i = 0; i < sizeof maxlen / sizeof maxlen[0]; i++) { if (values[i] && strlen(values[i]) > maxlen[i]) { failf(r, "INVALID_ARGS", "%s is too long", names[i]); return -1; } } if (in->have_payment && in->payment_days < 0) { fail(r, "INVALID_ARGS", "payment_days must be >= 0"); return -1; } return 0; } static yyjson_mut_val *customer_lookup(struct req *r, int64_t id, int *found) { sqlite3_stmt *st = NULL; *found = 0; if (sqlite3_prepare_v2( r->db, "SELECT " CUSTOMER_COLUMNS " FROM customers" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) { *found = -1; db_error(r); return NULL; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); return NULL; } yyjson_mut_val *o = customer_json(r, st); sqlite3_finalize(st); *found = 1; return o; } static int customer_name_taken(struct req *r, const char *name, int64_t except_id) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id FROM customers WHERE org_id=?1 AND name=?2" " AND id<>?3", -1, &st, NULL) != SQLITE_OK) { db_error(r); return -1; } sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 3, except_id); int taken = sqlite3_step(st) == SQLITE_ROW; sqlite3_finalize(st); return taken; } static yyjson_mut_val *h_customer_list(struct req *r) { int active_only = 0; arg_bool(r->args, "active_only", &active_only); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT " CUSTOMER_COLUMNS " FROM customers WHERE org_id=?1" " AND (?2=0 OR active=1) ORDER BY name COLLATE NOCASE, id", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int(st, 2, active_only); yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); while (sqlite3_step(st) == SQLITE_ROW) yyjson_mut_arr_add_val(items, customer_json(r, st)); 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_customer_get(struct req *r) { int64_t id = 0; if (!arg_int(r->args, "id", &id) || id <= 0) return fail(r, "INVALID_ARGS", "id is required"); int found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); return o; } static yyjson_mut_val *h_customer_create(struct req *r) { struct customer_input in; customer_input_read(r, &in); if (customer_input_validate(r, &in, 1) != 0) return NULL; if (!in.have_payment) in.payment_days = 30; const char *address = in.address ? in.address : ""; const char *postal = in.postal_code ? in.postal_code : ""; const char *city = in.city ? in.city : ""; const char *country = in.country ? in.country : "SE"; const char *vat = in.vat_nr ? in.vat_nr : ""; const char *email = in.email ? in.email : ""; const char *your_ref = in.your_ref ? in.your_ref : ""; const char *notes = in.notes ? in.notes : ""; int taken = customer_name_taken(r, in.name, 0); if (taken < 0) return NULL; if (taken) return fail(r, "CONFLICT", "a customer with this name already exists"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", 0); yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", in.name); yyjson_mut_obj_add_strcpy(r->rdoc, o, "address", address); yyjson_mut_obj_add_strcpy(r->rdoc, o, "postal_code", postal); yyjson_mut_obj_add_strcpy(r->rdoc, o, "city", city); yyjson_mut_obj_add_strcpy(r->rdoc, o, "country", country); yyjson_mut_obj_add_strcpy(r->rdoc, o, "vat_nr", vat); yyjson_mut_obj_add_strcpy(r->rdoc, o, "email", email); yyjson_mut_obj_add_strcpy(r->rdoc, o, "your_ref", your_ref); yyjson_mut_obj_add_int(r->rdoc, o, "payment_days", in.payment_days); yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); yyjson_mut_obj_add_bool(r->rdoc, o, "active", true); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO customers(org_id,name,address,postal_code,city,country," "vat_nr,email,your_ref,payment_days,notes,created_at)" " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, in.name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, address, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, postal, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, city, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, country, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, vat, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 8, email, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 9, your_ref, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 10, in.payment_days); sqlite3_bind_text(st, 11, notes, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 12, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "a customer with this name already exists"); return db_sqlite_error(r); } 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, "customer.create", reqjson, "OK", NULL); free(reqjson); int found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "INTERNAL", "could not read the new customer"); return o; } static yyjson_mut_val *h_customer_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"); struct customer_input in; customer_input_read(r, &in); if (customer_input_validate(r, &in, 0) != 0) return NULL; if (!in.name && !in.address && !in.postal_code && !in.city && !in.country && !in.vat_nr && !in.email && !in.your_ref && !in.notes && !in.have_payment && !in.have_active) return fail(r, "INVALID_ARGS", "nothing to update"); int found = 0; yyjson_mut_val *existing = customer_lookup(r, id, &found); (void)existing; if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); if (in.name) { int taken = customer_name_taken(r, in.name, id); if (taken < 0) return NULL; if (taken) return fail(r, "CONFLICT", "a customer with this name already exists"); } if (r->dry_run) { sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "SELECT id,COALESCE(?3,name),COALESCE(?4,address)," "COALESCE(?5,postal_code),COALESCE(?6,city)," "COALESCE(?7,country),COALESCE(?8,vat_nr)," "COALESCE(?9,email),COALESCE(?10,your_ref)," "CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," "COALESCE(?12,notes)," "CASE WHEN ?13<0 THEN active ELSE ?13 END," "created_at,COALESCE(updated_at,'')" " FROM customers WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); const char *texts[] = { in.name, in.address, in.postal_code, in.city, in.country, in.vat_nr, in.email, in.your_ref, in.notes }; static const int bind_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { bind_text_or_null(st, bind_index[i], texts[i]); } sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); yyjson_mut_val *o = NULL; if (sqlite3_step(st) == SQLITE_ROW) o = customer_json(r, st); sqlite3_finalize(st); if (!o) return fail(r, "NOT_FOUND", "customer not found"); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE customers SET" " name=COALESCE(?3,name), address=COALESCE(?4,address)," " postal_code=COALESCE(?5,postal_code), city=COALESCE(?6,city)," " country=COALESCE(?7,country), vat_nr=COALESCE(?8,vat_nr)," " email=COALESCE(?9,email), your_ref=COALESCE(?10,your_ref)," " payment_days=CASE WHEN ?11<0 THEN payment_days ELSE ?11 END," " notes=COALESCE(?12,notes)," " active=CASE WHEN ?13<0 THEN active ELSE ?13 END," " updated_at=?14 WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); const char *texts[] = { in.name, in.address, in.postal_code, in.city, in.country, in.vat_nr, in.email, in.your_ref, in.notes }; static const int text_index[] = { 3, 4, 5, 6, 7, 8, 9, 10, 12 }; for (size_t i = 0; i < sizeof texts / sizeof texts[0]; i++) { bind_text_or_null(st, text_index[i], texts[i]); } sqlite3_bind_int64(st, 11, in.have_payment ? in.payment_days : -1); sqlite3_bind_int64(st, 13, in.have_active ? in.active : -1); sqlite3_bind_text(st, 14, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) { if ((rc & 0xff) == SQLITE_CONSTRAINT) return fail(r, "CONFLICT", "a customer with this name already exists"); return db_sqlite_error(r); } if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "customer not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "customer.update", reqjson, "OK", NULL); free(reqjson); found = 0; yyjson_mut_val *o = customer_lookup(r, id, &found); if (found < 0) return NULL; if (!found) return fail(r, "INTERNAL", "could not read the customer"); return o; } static yyjson_mut_val *h_customer_archive(struct req *r) { int64_t id = 0; int active = 0; if (!arg_int(r->args, "id", &id) || id <= 0 || !arg_bool(r->args, "active", &active)) return fail(r, "INVALID_ARGS", "id and active are required"); int found = 0; yyjson_mut_val *existing = customer_lookup(r, id, &found); (void)existing; if (found < 0) return NULL; if (!found) return fail(r, "NOT_FOUND", "customer not found"); if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "active", active != 0); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "UPDATE customers SET active=?3, updated_at=?4" " WHERE org_id=?1 AND id=?2", -1, &st, NULL) != SQLITE_OK) return db_error(r); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int(st, 3, active); sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) return db_sqlite_error(r); if (sqlite3_changes(r->db) == 0) return fail(r, "NOT_FOUND", "customer not found"); char *reqjson = audit_args_json(r->args); audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, "customer.archive", reqjson, "OK", NULL); free(reqjson); yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); yyjson_mut_obj_add_int(r->rdoc, o, "id", id); yyjson_mut_obj_add_bool(r->rdoc, o, "active", active != 0); return o; } static const struct cmd_arg args_customer_list[] = { { "active_only", ARG_BOOL, 0, NULL, NULL, "Only active customers" }, }; static const struct cmd_arg args_customer_get[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, }; static const struct cmd_arg args_customer_create[] = { { "name", ARG_STR, 1, NULL, NULL, "Customer name, unique per org" }, { "address", ARG_STR, 0, NULL, NULL, "Street address; may contain newlines" }, { "postal_code", ARG_STR, 0, NULL, NULL, "Postal code" }, { "city", ARG_STR, 0, NULL, NULL, "City" }, { "country", ARG_STR, 0, "SE", NULL, "Country code" }, { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "payment_days", ARG_INT, 0, "30", NULL, "Payment terms in days" }, }; static const struct cmd_arg args_customer_update[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "name", ARG_STR, 0, NULL, NULL, "Customer name, unique per org" }, { "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 code" }, { "vat_nr", ARG_STR, 0, NULL, NULL, "VAT number" }, { "email", ARG_STR, 0, NULL, NULL, "E-mail address" }, { "your_ref", ARG_STR, 0, NULL, NULL, "Customer reference" }, { "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" }, { "payment_days", ARG_INT, 0, NULL, NULL, "Payment terms in days" }, { "active", ARG_BOOL, 0, NULL, NULL, "Active flag" }, }; static const struct cmd_arg args_customer_archive[] = { { "id", ARG_INT, 1, NULL, NULL, "Customer id" }, { "active", ARG_BOOL, 1, NULL, NULL, "false archives, true reactivates" }, }; const struct command g_cmd_customers[] = { { "customer.list", "List customers ordered by name", PERM_READ, 1, 0, 0, h_customer_list, CMD_ARGS(args_customer_list) }, { "customer.get", "Get one customer", PERM_READ, 1, 0, 0, h_customer_get, CMD_ARGS(args_customer_get) }, { "customer.create", "Create a customer", PERM_WRITE, 1, 1, 1, h_customer_create, CMD_ARGS(args_customer_create) }, { "customer.update", "Update a customer (merged)", PERM_WRITE, 1, 1, 1, h_customer_update, CMD_ARGS(args_customer_update) }, { "customer.archive", "Archive or reactivate a customer", PERM_WRITE, 1, 1, 1, h_customer_archive, CMD_ARGS(args_customer_archive) }, }; const struct cmd_table g_cmd_table_customers = { g_cmd_customers, sizeof g_cmd_customers / sizeof g_cmd_customers[0] };