diff options
Diffstat (limited to 'src/cmd_customers.c')
| -rw-r--r-- | src/cmd_customers.c | 517 |
1 files changed, 517 insertions, 0 deletions
diff --git a/src/cmd_customers.c b/src/cmd_customers.c new file mode 100644 index 0000000..a46cd32 --- /dev/null +++ b/src/cmd_customers.c @@ -0,0 +1,517 @@ +#include "commands.h" +#include "cmd_util.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#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; + fail(r, "INTERNAL", "database error"); + 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) { + fail(r, "INTERNAL", "database error"); + 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 fail(r, "INTERNAL", "database error"); + 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 fail(r, "INTERNAL", "database error"); + 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 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, + "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 fail(r, "INTERNAL", "database error"); + 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++) { + if (texts[i]) + sqlite3_bind_text(st, bind_index[i], texts[i], -1, + SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, bind_index[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 fail(r, "INTERNAL", "database error"); + 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++) { + if (texts[i]) + sqlite3_bind_text(st, text_index[i], texts[i], -1, + SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, text_index[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 fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + } + 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 fail(r, "INTERNAL", "database error"); + 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 fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + 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] +}; |
