using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** * Stack storage (goal 8 task 2 B): CRUD over `stacks`, the stack's * project members in `stack_projects` (relationship 'co-developed' * or 'reference', validated like the project link graph), and the * two apply paths — a stack's members land as `project_links` rows * on a project or as `goal_links` rows on a goal, skipping exact * duplicates (same target, same relationship) so re-applying is * idempotent. */ public class StackService : Object { private OrmSession db = inject(); private ProjectService projects = inject(); private GoalService goals = inject(); private StatePulse pulse = inject(); /** Whether `relationship` is a valid stack-member relationship. */ private void require_valid_relationship(string relationship) throws Error { if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) { throw new Error(Quark.from_string("clanker-saddle"), 90, @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'"); } } /** Inserts a stack; `summary` may be empty. */ public async Stack create_stack(string name, string? summary) throws Error { string trimmed = name.strip(); if (trimmed.length == 0) { throw new Error(Quark.from_string("clanker-saddle"), 91, "A stack name is required"); } var existing = yield get_stack_by_name(trimmed); if (existing != null) { throw new Error(Quark.from_string("clanker-saddle"), 92, @"A stack named '$(trimmed)' already exists"); } var stack = new Stack() { name = trimmed, summary = summary != null && summary.strip().length > 0 ? summary.strip() : null, created_at = new DateTime.now_local() }; yield db.insert_async(stack); yield pulse.bump(0, "stack created"); return stack; } /** Loads the stack identified by `id`, or null. */ public async Stack? get_stack(int64 id) throws Error { return yield db.query() .where(expr("id == $0", elem(id))) .first_async(); } /** Loads the stack identified by `name`, or null. */ public async Stack? get_stack_by_name(string name) throws Error { return yield db.query() .where(expr("name == $0", elem(name))) .first_async(); } /** Every stack, newest first. */ public async ImmutableLot list_stacks() throws Error { return yield db.query() .order_by_desc(expr("id")) .materialise_async(); } /** * Saves the editable fields of the stack identified by `id`; * null/empty arguments keep their current values. Returns the * saved stack, or null when no such stack exists. */ public async Stack? update_stack(int64 id, string? name, string? summary) throws Error { var stack = yield get_stack(id); if (stack == null) { return null; } if (name != null && name.strip().length > 0) { string trimmed = name.strip(); var clash = yield get_stack_by_name(trimmed); if (clash != null && ((!)clash).id != id) { throw new Error(Quark.from_string("clanker-saddle"), 93, @"A stack named '$(trimmed)' already exists"); } ((!)stack).name = trimmed; } if (summary != null) { ((!)stack).summary = summary.strip().length > 0 ? summary.strip() : null; } yield db.update_async((!)stack); yield pulse.bump(0, "stack saved"); return stack; } /** Deletes the stack and its members. */ public async void delete_stack(int64 id) throws Error { var stack = yield get_stack(id); if (stack == null) { return; } foreach (var member in yield list_members(id)) { yield db.delete_async(member); } yield db.delete_async((!)stack); yield pulse.bump(0, "stack deleted"); } /** The stack's member rows, oldest first. */ public async ImmutableLot list_members(int64 stack_id) throws Error { return yield db.query() .where(expr("stack_id == $0", elem(stack_id))) .order_by(expr("id")) .materialise_async(); } /** * Adds a project to the stack with `relationship`; * duplicates (same stack, same project) are rejected. */ public async StackProject add_member(int64 stack_id, int64 project_id, string relationship) throws Error { require_valid_relationship(relationship); var stack = yield get_stack(stack_id); if (stack == null) { throw new Error(Quark.from_string("clanker-saddle"), 94, @"No stack exists with id $(stack_id)"); } var project = yield projects.get_project(project_id); if (project == null) { throw new Error(Quark.from_string("clanker-saddle"), 95, @"No project exists with id $(project_id)"); } var existing = yield db.query() .where(expr("stack_id == $0 && project_id == $1", elem(stack_id), elem(project_id))) .materialise_async(); if (existing.length > 0) { throw new Error(Quark.from_string("clanker-saddle"), 96, "That project is already in the stack"); } var member = new StackProject() { stack_id = stack_id, project_id = project_id, relationship = relationship, created_at = new DateTime.now_local() }; yield db.insert_async(member); yield pulse.bump(0, "stack member added"); return member; } /** Removes the stack member identified by `id`. */ public async void remove_member(int64 member_id) throws Error { var member = yield db.query() .where(expr("id == $0", elem(member_id))) .first_async(); if (member != null) { yield db.delete_async((!)member); yield pulse.bump(0, "stack member removed"); } } /** * Applies the stack to a project: every member becomes a * `project_links` row on `parent_id`, same relationship, as * long as the parent does not already carry that exact pair * (same target AND same relationship — a different * relationship on the same target stays). Returns how many * links landed. Never throws on duplicates: re-applying is * idempotent by design. */ public async int64 apply_to_project(int64 stack_id, int64 parent_id) throws Error { var stack = yield require_stack(stack_id); var parent = yield projects.get_project(parent_id); if (parent == null) { throw new Error(Quark.from_string("clanker-saddle"), 97, @"No project exists with id $(parent_id)"); } int64 added = 0; foreach (var member in yield list_members(stack_id)) { if (member.project_id == parent_id) { continue; // a project never links to itself } bool duplicate = false; foreach (var link in yield projects.list_links(parent_id)) { if (link.linked_project_id == member.project_id && link.relationship == member.relationship) { duplicate = true; break; } } if (duplicate) { continue; } yield db.insert_async(new ProjectLink() { parent_project_id = parent_id, linked_project_id = member.project_id, relationship = member.relationship }); added++; } yield pulse.bump(0, @"stack $(((!)stack).name) applied to project"); return added; } /** * Applies the stack to a goal: every member becomes a * `goal_links` row on `goal_id`, same relationship, skipping * exact duplicates. The primary project is implicitly linked * and never duplicated. Returns how many links landed. */ public async int64 apply_to_goal(int64 stack_id, int64 goal_id) throws Error { var stack = yield require_stack(stack_id); var goal = yield goals.get_goal(goal_id); if (goal == null) { throw new Error(Quark.from_string("clanker-saddle"), 98, @"No goal exists with id $(goal_id)"); } int64 added = 0; foreach (var member in yield list_members(stack_id)) { if (member.project_id == ((!)goal).project_id) { continue; // the primary project is implicitly linked } bool duplicate = false; foreach (var link in yield goals.list_links(goal_id)) { if (link.project_id == member.project_id && link.relationship == member.relationship) { duplicate = true; break; } } if (duplicate) { continue; } yield db.insert_async(new GoalLink() { goal_id = goal_id, project_id = member.project_id, relationship = member.relationship }); added++; } yield pulse.bump(0, @"stack $(((!)stack).name) applied to goal"); return added; } /** Loads the stack or throws (the apply paths' guard). */ private async Stack require_stack(int64 id) throws Error { var stack = yield get_stack(id); if (stack == null) { throw new Error(Quark.from_string("clanker-saddle"), 94, @"No stack exists with id $(id)"); } return (!)stack; } } }