diff --git a/.sqlx/query-13dbb14af1e58057e6d710f4ca703e9f0109ce3b19965c7320274636f135cc50.json b/.sqlx/query-13dbb14af1e58057e6d710f4ca703e9f0109ce3b19965c7320274636f135cc50.json new file mode 100644 index 00000000..711cda4f --- /dev/null +++ b/.sqlx/query-13dbb14af1e58057e6d710f4ca703e9f0109ce3b19965c7320274636f135cc50.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM organizations WHERE id=$1 FOR SHARE NOWAIT", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "13dbb14af1e58057e6d710f4ca703e9f0109ce3b19965c7320274636f135cc50" +} diff --git a/.sqlx/query-170f35bdb929b10efb69b5dbc07aaaa09fa7eb47b66bd05f9331a8f2a2b66712.json b/.sqlx/query-170f35bdb929b10efb69b5dbc07aaaa09fa7eb47b66bd05f9331a8f2a2b66712.json new file mode 100644 index 00000000..fcc0fdec --- /dev/null +++ b/.sqlx/query-170f35bdb929b10efb69b5dbc07aaaa09fa7eb47b66bd05f9331a8f2a2b66712.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.id FROM projects p JOIN project_tasks pt ON pt.project_id=p.id\n JOIN tasks t ON t.id=pt.task_id AND t.org_id=p.org_id\n WHERE p.org_id=$1 AND t.id=$2 ORDER BY p.id FOR NO KEY UPDATE OF p", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "170f35bdb929b10efb69b5dbc07aaaa09fa7eb47b66bd05f9331a8f2a2b66712" +} diff --git a/.sqlx/query-c34d23ea0ff2795e97bf8c13aba8b37c19b00978c4f0b7e0d11ae49beb46072a.json b/.sqlx/query-1d04d85ae7bd2c826fe46c78ae453a0f5c6cc27a684802114a3e3abec454191b.json similarity index 61% rename from .sqlx/query-c34d23ea0ff2795e97bf8c13aba8b37c19b00978c4f0b7e0d11ae49beb46072a.json rename to .sqlx/query-1d04d85ae7bd2c826fe46c78ae453a0f5c6cc27a684802114a3e3abec454191b.json index 1fb1a077..4c23c570 100644 --- a/.sqlx/query-c34d23ea0ff2795e97bf8c13aba8b37c19b00978c4f0b7e0d11ae49beb46072a.json +++ b/.sqlx/query-1d04d85ae7bd2c826fe46c78ae453a0f5c6cc27a684802114a3e3abec454191b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT p.id AS project_id,p.name AS project_name,t.id AS task_id,t.name AS task_name,\n (p.project_type <> 'non_billable' AND pt.billable) AS \"billable!\"\n FROM users u JOIN projects p ON p.org_id=u.org_id\n JOIN clients c ON c.id=p.client_id AND c.org_id=u.org_id\n JOIN project_tasks pt ON pt.project_id=p.id\n JOIN tasks t ON t.id=pt.task_id AND t.org_id=u.org_id\n WHERE u.org_id=$1 AND u.id=$2 AND u.active AND p.active AND c.active AND t.active\n AND ($3 OR p.id=ANY($4))\n AND (($5 AND u.org_role='admin') OR EXISTS (\n SELECT 1 FROM assignments a WHERE a.user_id=u.id AND a.project_id=p.id))\n AND NOT EXISTS (SELECT 1 FROM project_task_settings s\n WHERE s.org_id=u.org_id AND s.project_id=p.id AND s.task_id=t.id AND s.restricted\n AND NOT EXISTS (SELECT 1 FROM project_task_members m\n WHERE m.org_id=u.org_id AND m.project_id=p.id AND m.task_id=t.id AND m.user_id=u.id))\n AND ($6::uuid IS NULL OR p.id=$6) AND ($7::uuid IS NULL OR t.id=$7)\n ORDER BY p.name,p.id,t.name,t.id FOR SHARE OF p,c,pt,t", + "query": "SELECT p.id AS project_id,p.name AS project_name,t.id AS task_id,t.name AS task_name,\n (p.project_type <> 'non_billable' AND pt.billable) AS \"billable!\"\n FROM users u JOIN projects p ON p.org_id=u.org_id\n JOIN clients c ON c.id=p.client_id AND c.org_id=u.org_id\n JOIN project_tasks pt ON pt.project_id=p.id\n JOIN tasks t ON t.id=pt.task_id AND t.org_id=u.org_id\n WHERE u.org_id=$1 AND u.id=$2 AND u.active AND p.active AND c.active AND t.active\n AND ($5 OR pt.active)\n AND ($3 OR p.id=ANY($4))\n AND (($5 AND u.org_role='admin') OR EXISTS (\n SELECT 1 FROM assignments a WHERE a.user_id=u.id AND a.project_id=p.id))\n AND NOT EXISTS (SELECT 1 FROM project_task_settings s\n WHERE s.org_id=u.org_id AND s.project_id=p.id AND s.task_id=t.id AND s.restricted\n AND NOT EXISTS (SELECT 1 FROM project_task_members m\n WHERE m.org_id=u.org_id AND m.project_id=p.id AND m.task_id=t.id AND m.user_id=u.id))\n AND ($6::uuid IS NULL OR p.id=$6) AND ($7::uuid IS NULL OR t.id=$7)\n ORDER BY p.name,p.id,t.name,t.id FOR SHARE OF p,c,pt,t", "describe": { "columns": [ { @@ -48,5 +48,5 @@ null ] }, - "hash": "c34d23ea0ff2795e97bf8c13aba8b37c19b00978c4f0b7e0d11ae49beb46072a" + "hash": "1d04d85ae7bd2c826fe46c78ae453a0f5c6cc27a684802114a3e3abec454191b" } diff --git a/.sqlx/query-2a22de0eb8e02e1e21e26ffda2c2fdf6969e24fdbb98c70bcc8413dc5a9d8677.json b/.sqlx/query-2a22de0eb8e02e1e21e26ffda2c2fdf6969e24fdbb98c70bcc8413dc5a9d8677.json deleted file mode 100644 index 27415cf2..00000000 --- a/.sqlx/query-2a22de0eb8e02e1e21e26ffda2c2fdf6969e24fdbb98c70bcc8413dc5a9d8677.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents)\n SELECT r.project_id, r.task_id, r.billable, r.rate_cents\n FROM jsonb_populate_recordset(NULL::project_tasks, $1) r\n WHERE (SELECT org_id FROM projects WHERE id = r.project_id) = $2\n AND (SELECT org_id FROM tasks WHERE id = r.task_id) = $2\n ON CONFLICT (project_id, task_id) DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "2a22de0eb8e02e1e21e26ffda2c2fdf6969e24fdbb98c70bcc8413dc5a9d8677" -} diff --git a/.sqlx/query-2a61723eb1e7c4b62c664e952f6a38f9bd5891865636dd809bace4a8dbb99802.json b/.sqlx/query-2a61723eb1e7c4b62c664e952f6a38f9bd5891865636dd809bace4a8dbb99802.json new file mode 100644 index 00000000..8e9ffcd7 --- /dev/null +++ b/.sqlx/query-2a61723eb1e7c4b62c664e952f6a38f9bd5891865636dd809bace4a8dbb99802.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_tasks SET active=$3 WHERE project_id=$1 AND task_id=$2 AND active IS DISTINCT FROM $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "2a61723eb1e7c4b62c664e952f6a38f9bd5891865636dd809bace4a8dbb99802" +} diff --git a/.sqlx/query-2fbdfe1941705bb5c7f28e716f290ebd3ccf69dc3c79316cbc13500ad03496d5.json b/.sqlx/query-2fbdfe1941705bb5c7f28e716f290ebd3ccf69dc3c79316cbc13500ad03496d5.json new file mode 100644 index 00000000..c4e69bfa --- /dev/null +++ b/.sqlx/query-2fbdfe1941705bb5c7f28e716f290ebd3ccf69dc3c79316cbc13500ad03496d5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_tasks (project_id,task_id,billable,rate_cents,active) VALUES ($1,$2,true,2500,false)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2fbdfe1941705bb5c7f28e716f290ebd3ccf69dc3c79316cbc13500ad03496d5" +} diff --git a/.sqlx/query-3488c0f4f43911266b1f9488a0d66f5f5cb0a83c5e08ad0e3765ee7e6461784a.json b/.sqlx/query-3488c0f4f43911266b1f9488a0d66f5f5cb0a83c5e08ad0e3765ee7e6461784a.json new file mode 100644 index 00000000..3124de62 --- /dev/null +++ b/.sqlx/query-3488c0f4f43911266b1f9488a0d66f5f5cb0a83c5e08ad0e3765ee7e6461784a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT xmin::text AS version,default_rate_cents,default_rate_currency FROM tasks WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "default_rate_cents", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "default_rate_currency", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null, + true, + true + ] + }, + "hash": "3488c0f4f43911266b1f9488a0d66f5f5cb0a83c5e08ad0e3765ee7e6461784a" +} diff --git a/.sqlx/query-389ddd6db26be561ad1040efe580f4d5534ce46b457a4463afea5ae710d1d2ff.json b/.sqlx/query-389ddd6db26be561ad1040efe580f4d5534ce46b457a4463afea5ae710d1d2ff.json new file mode 100644 index 00000000..26954e62 --- /dev/null +++ b/.sqlx/query-389ddd6db26be561ad1040efe580f4d5534ce46b457a4463afea5ae710d1d2ff.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active FROM tasks WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "389ddd6db26be561ad1040efe580f4d5534ce46b457a4463afea5ae710d1d2ff" +} diff --git a/.sqlx/query-4052f5d5f960b45172a1d1311d297355fb0340cac1ce35cee6e1e42afd400ec9.json b/.sqlx/query-4052f5d5f960b45172a1d1311d297355fb0340cac1ce35cee6e1e42afd400ec9.json new file mode 100644 index 00000000..de024af0 --- /dev/null +++ b/.sqlx/query-4052f5d5f960b45172a1d1311d297355fb0340cac1ce35cee6e1e42afd400ec9.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active, rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "rate_cents", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "4052f5d5f960b45172a1d1311d297355fb0340cac1ce35cee6e1e42afd400ec9" +} diff --git a/.sqlx/query-4768e774112fe9ef1a86c371db44f714d4fce30108c7734a4d79b2870c0e951d.json b/.sqlx/query-4768e774112fe9ef1a86c371db44f714d4fce30108c7734a4d79b2870c0e951d.json new file mode 100644 index 00000000..9b800b05 --- /dev/null +++ b/.sqlx/query-4768e774112fe9ef1a86c371db44f714d4fce30108c7734a4d79b2870c0e951d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_tasks SET active=false, rate_cents=2500 WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4768e774112fe9ef1a86c371db44f714d4fce30108c7734a4d79b2870c0e951d" +} diff --git a/.sqlx/query-526edf9a9fcb2f95178491fa3c11388d43be20df68f209187edb2e13bf02d1e3.json b/.sqlx/query-526edf9a9fcb2f95178491fa3c11388d43be20df68f209187edb2e13bf02d1e3.json new file mode 100644 index 00000000..de298c1b --- /dev/null +++ b/.sqlx/query-526edf9a9fcb2f95178491fa3c11388d43be20df68f209187edb2e13bf02d1e3.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active,billable,rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "billable", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "rate_cents", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "526edf9a9fcb2f95178491fa3c11388d43be20df68f209187edb2e13bf02d1e3" +} diff --git a/.sqlx/query-532a6cb313c7b9f55583d5e2411fe49b0d2cefeac28185d16f0f0eb782c9a089.json b/.sqlx/query-532a6cb313c7b9f55583d5e2411fe49b0d2cefeac28185d16f0f0eb782c9a089.json new file mode 100644 index 00000000..c309db23 --- /dev/null +++ b/.sqlx/query-532a6cb313c7b9f55583d5e2411fe49b0d2cefeac28185d16f0f0eb782c9a089.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT minutes,billable,task_id FROM time_entries WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "minutes", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "billable", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "task_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "532a6cb313c7b9f55583d5e2411fe49b0d2cefeac28185d16f0f0eb782c9a089" +} diff --git a/.sqlx/query-56b3d112f6e560ce73d8403a129d503a740c3267af51ea93cfa185d105ed8091.json b/.sqlx/query-56b3d112f6e560ce73d8403a129d503a740c3267af51ea93cfa185d105ed8091.json new file mode 100644 index 00000000..f3c9fcd8 --- /dev/null +++ b/.sqlx/query-56b3d112f6e560ce73d8403a129d503a740c3267af51ea93cfa185d105ed8091.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active FROM project_tasks WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "56b3d112f6e560ce73d8403a129d503a740c3267af51ea93cfa185d105ed8091" +} diff --git a/.sqlx/query-593502010b39a4d9dc7c0cbb005e7ca33e294c4e038f055b2cd9a4fe472b0d22.json b/.sqlx/query-593502010b39a4d9dc7c0cbb005e7ca33e294c4e038f055b2cd9a4fe472b0d22.json new file mode 100644 index 00000000..65ecf20d --- /dev/null +++ b/.sqlx/query-593502010b39a4d9dc7c0cbb005e7ca33e294c4e038f055b2cd9a4fe472b0d22.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT sum(minutes)::bigint FROM time_entries WHERE org_id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sum", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "593502010b39a4d9dc7c0cbb005e7ca33e294c4e038f055b2cd9a4fe472b0d22" +} diff --git a/.sqlx/query-62e920fd75262a908e59f07aa804a2b860d6c00833a90246027644f00f5828d3.json b/.sqlx/query-62e920fd75262a908e59f07aa804a2b860d6c00833a90246027644f00f5828d3.json new file mode 100644 index 00000000..dba085b2 --- /dev/null +++ b/.sqlx/query-62e920fd75262a908e59f07aa804a2b860d6c00833a90246027644f00f5828d3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE time_entries SET is_running=true,started_at=now() WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "62e920fd75262a908e59f07aa804a2b860d6c00833a90246027644f00f5828d3" +} diff --git a/.sqlx/query-64568ca0c4362d86c605a95daa855acc4a83b83078c98dba63011264cb85b903.json b/.sqlx/query-64568ca0c4362d86c605a95daa855acc4a83b83078c98dba63011264cb85b903.json new file mode 100644 index 00000000..a0cbda6b --- /dev/null +++ b/.sqlx/query-64568ca0c4362d86c605a95daa855acc4a83b83078c98dba63011264cb85b903.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET grants=ARRAY['unknown.permission'] WHERE user_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "64568ca0c4362d86c605a95daa855acc4a83b83078c98dba63011264cb85b903" +} diff --git a/.sqlx/query-7115ef390aaba302c912f36b7f870bd9aeedd48b539189b6621b4703c1b4be82.json b/.sqlx/query-7115ef390aaba302c912f36b7f870bd9aeedd48b539189b6621b4703c1b4be82.json new file mode 100644 index 00000000..0b5905e1 --- /dev/null +++ b/.sqlx/query-7115ef390aaba302c912f36b7f870bd9aeedd48b539189b6621b4703c1b4be82.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_task_settings SET budget_minutes=$3,budget_cents=$4,restricted=true WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7115ef390aaba302c912f36b7f870bd9aeedd48b539189b6621b4703c1b4be82" +} diff --git a/.sqlx/query-71322235d87613284b09e1acc30f1def1af64c2131cd0f6188c6e3d67a078e77.json b/.sqlx/query-71322235d87613284b09e1acc30f1def1af64c2131cd0f6188c6e3d67a078e77.json new file mode 100644 index 00000000..41206809 --- /dev/null +++ b/.sqlx/query-71322235d87613284b09e1acc30f1def1af64c2131cd0f6188c6e3d67a078e77.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE organizations SET permission_policy_version=2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "71322235d87613284b09e1acc30f1def1af64c2131cd0f6188c6e3d67a078e77" +} diff --git a/.sqlx/query-842375a58bc219385591fd6d2ab7d069c5ed902e4147b1495e9a56b92107a244.json b/.sqlx/query-74b205eac4535d9fbed53a61400fc2106efd5aacdf824a9a9b4c611b1ad25268.json similarity index 64% rename from .sqlx/query-842375a58bc219385591fd6d2ab7d069c5ed902e4147b1495e9a56b92107a244.json rename to .sqlx/query-74b205eac4535d9fbed53a61400fc2106efd5aacdf824a9a9b4c611b1ad25268.json index eb2a643c..681db5c7 100644 --- a/.sqlx/query-842375a58bc219385591fd6d2ab7d069c5ed902e4147b1495e9a56b92107a244.json +++ b/.sqlx/query-74b205eac4535d9fbed53a61400fc2106efd5aacdf824a9a9b4c611b1ad25268.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT t.id, t.name, t.active, t.billable_default, t.default_rate_cents, t.default_rate_currency\n FROM tasks t JOIN project_tasks pt ON pt.task_id = t.id\n WHERE pt.project_id = $1 AND t.org_id = $2 ORDER BY t.id", + "query": "SELECT t.id, t.name, t.active, pt.active AS link_active, t.billable_default, t.default_rate_cents, t.default_rate_currency\n FROM tasks t JOIN project_tasks pt ON pt.task_id = t.id\n WHERE pt.project_id = $1 AND t.org_id = $2 ORDER BY t.id", "describe": { "columns": [ { @@ -20,16 +20,21 @@ }, { "ordinal": 3, - "name": "billable_default", + "name": "link_active", "type_info": "Bool" }, { "ordinal": 4, + "name": "billable_default", + "type_info": "Bool" + }, + { + "ordinal": 5, "name": "default_rate_cents", "type_info": "Int8" }, { - "ordinal": 5, + "ordinal": 6, "name": "default_rate_currency", "type_info": "Text" } @@ -45,9 +50,10 @@ false, false, false, + false, true, true ] }, - "hash": "842375a58bc219385591fd6d2ab7d069c5ed902e4147b1495e9a56b92107a244" + "hash": "74b205eac4535d9fbed53a61400fc2106efd5aacdf824a9a9b4c611b1ad25268" } diff --git a/.sqlx/query-8259f14870504b178510ceca9c7c3a59446600431bd11ad329d417e6d42f300f.json b/.sqlx/query-8259f14870504b178510ceca9c7c3a59446600431bd11ad329d417e6d42f300f.json new file mode 100644 index 00000000..d92e53b9 --- /dev/null +++ b/.sqlx/query-8259f14870504b178510ceca9c7c3a59446600431bd11ad329d417e6d42f300f.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active, billable, rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "billable", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "rate_cents", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "8259f14870504b178510ceca9c7c3a59446600431bd11ad329d417e6d42f300f" +} diff --git a/.sqlx/query-8aae7affad6348e6e22450a3fd8abf99d1fdc551c9745347937da32b3ae17833.json b/.sqlx/query-8aae7affad6348e6e22450a3fd8abf99d1fdc551c9745347937da32b3ae17833.json deleted file mode 100644 index f5a5c1f7..00000000 --- a/.sqlx/query-8aae7affad6348e6e22450a3fd8abf99d1fdc551c9745347937da32b3ae17833.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (project_id, task_id) DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Bool", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "8aae7affad6348e6e22450a3fd8abf99d1fdc551c9745347937da32b3ae17833" -} diff --git a/.sqlx/query-8ddcc2cfbb3d35225e4fad60eaf256f0feadaf10a4fd5d0a54f823fc10f73313.json b/.sqlx/query-8ddcc2cfbb3d35225e4fad60eaf256f0feadaf10a4fd5d0a54f823fc10f73313.json new file mode 100644 index 00000000..246b0ad7 --- /dev/null +++ b/.sqlx/query-8ddcc2cfbb3d35225e4fad60eaf256f0feadaf10a4fd5d0a54f823fc10f73313.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active FROM tasks WHERE id=$1 AND org_id=$2 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8ddcc2cfbb3d35225e4fad60eaf256f0feadaf10a4fd5d0a54f823fc10f73313" +} diff --git a/.sqlx/query-8fdd9531d8a233def8a6f7b2685312f1871fdd215a386647ed1b2339d48321b9.json b/.sqlx/query-8fdd9531d8a233def8a6f7b2685312f1871fdd215a386647ed1b2339d48321b9.json new file mode 100644 index 00000000..4eab912a --- /dev/null +++ b/.sqlx/query-8fdd9531d8a233def8a6f7b2685312f1871fdd215a386647ed1b2339d48321b9.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents, active)\n VALUES ($1, $2, $3, $4, (\n SELECT o.permission_policy_version=0 OR (o.permission_policy_version=1 AND t.active)\n FROM tasks t JOIN projects p ON p.org_id=t.org_id\n JOIN organizations o ON o.id=p.org_id WHERE p.id=$1 AND t.id=$2\n ))\n ON CONFLICT (project_id, task_id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8fdd9531d8a233def8a6f7b2685312f1871fdd215a386647ed1b2339d48321b9" +} diff --git a/.sqlx/query-af12dd40190480f3d26ba491695e44714c314f2564e551e744e0478abe1d915d.json b/.sqlx/query-af12dd40190480f3d26ba491695e44714c314f2564e551e744e0478abe1d915d.json new file mode 100644 index 00000000..5d3d2263 --- /dev/null +++ b/.sqlx/query-af12dd40190480f3d26ba491695e44714c314f2564e551e744e0478abe1d915d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE tasks SET active=true WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "af12dd40190480f3d26ba491695e44714c314f2564e551e744e0478abe1d915d" +} diff --git a/.sqlx/query-c174c2dd89cebef95e919aff6d6c2b8ab57b0255fd780b93b37e7ccd9783e8aa.json b/.sqlx/query-c174c2dd89cebef95e919aff6d6c2b8ab57b0255fd780b93b37e7ccd9783e8aa.json new file mode 100644 index 00000000..37fe780e --- /dev/null +++ b/.sqlx/query-c174c2dd89cebef95e919aff6d6c2b8ab57b0255fd780b93b37e7ccd9783e8aa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_tasks pt SET active=false FROM projects p\n WHERE pt.project_id=p.id AND p.org_id=$1 AND pt.task_id=$2 AND pt.active", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c174c2dd89cebef95e919aff6d6c2b8ab57b0255fd780b93b37e7ccd9783e8aa" +} diff --git a/.sqlx/query-d1fb2223d84717f5592d3fd1b8ca654897fdc1ae8b0bfd0ea0cbe810da254bba.json b/.sqlx/query-d1fb2223d84717f5592d3fd1b8ca654897fdc1ae8b0bfd0ea0cbe810da254bba.json new file mode 100644 index 00000000..0ac12ee9 --- /dev/null +++ b/.sqlx/query-d1fb2223d84717f5592d3fd1b8ca654897fdc1ae8b0bfd0ea0cbe810da254bba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT edit_revision FROM projects WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "edit_revision", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d1fb2223d84717f5592d3fd1b8ca654897fdc1ae8b0bfd0ea0cbe810da254bba" +} diff --git a/.sqlx/query-d38a0c8706c0b945b6cf9a7801545fc62eaf58ad54a7c0022e820cb43ccdebc5.json b/.sqlx/query-d38a0c8706c0b945b6cf9a7801545fc62eaf58ad54a7c0022e820cb43ccdebc5.json new file mode 100644 index 00000000..4d9bbd65 --- /dev/null +++ b/.sqlx/query-d38a0c8706c0b945b6cf9a7801545fc62eaf58ad54a7c0022e820cb43ccdebc5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT xmin::text FROM tasks WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "xmin", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d38a0c8706c0b945b6cf9a7801545fc62eaf58ad54a7c0022e820cb43ccdebc5" +} diff --git a/.sqlx/query-d6ffb1b21191edef00e5a1df33944889e42f851a6d720c9a3bd70d5c54f11255.json b/.sqlx/query-d6ffb1b21191edef00e5a1df33944889e42f851a6d720c9a3bd70d5c54f11255.json new file mode 100644 index 00000000..0f197e9e --- /dev/null +++ b/.sqlx/query-d6ffb1b21191edef00e5a1df33944889e42f851a6d720c9a3bd70d5c54f11255.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM time_entry_contexts WHERE user_id=$1 AND project_id=$2 AND task_id=$3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d6ffb1b21191edef00e5a1df33944889e42f851a6d720c9a3bd70d5c54f11255" +} diff --git a/.sqlx/query-dc2a3ba5f21d327b4984a297f52744fc24626506bc767910def493b09c1730cd.json b/.sqlx/query-dc2a3ba5f21d327b4984a297f52744fc24626506bc767910def493b09c1730cd.json new file mode 100644 index 00000000..f3ab4ba9 --- /dev/null +++ b/.sqlx/query-dc2a3ba5f21d327b4984a297f52744fc24626506bc767910def493b09c1730cd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents, active)\n SELECT r.project_id, r.task_id, r.billable, r.rate_cents,\n COALESCE(r.active, (\n SELECT o.permission_policy_version=0 OR (o.permission_policy_version=1 AND t.active)\n FROM tasks t JOIN organizations o ON o.id=t.org_id\n WHERE t.id=r.task_id AND o.id=$2\n ))\n FROM jsonb_populate_recordset(NULL::project_tasks, $1) r\n WHERE (SELECT org_id FROM projects WHERE id = r.project_id) = $2\n AND (SELECT org_id FROM tasks WHERE id = r.task_id) = $2\n ON CONFLICT (project_id, task_id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "dc2a3ba5f21d327b4984a297f52744fc24626506bc767910def493b09c1730cd" +} diff --git a/.sqlx/query-e0d0db7115b5cdbcdeae935bd6546244efb6918d1932592321151f0bb5a8a245.json b/.sqlx/query-e0d0db7115b5cdbcdeae935bd6546244efb6918d1932592321151f0bb5a8a245.json new file mode 100644 index 00000000..5873bae7 --- /dev/null +++ b/.sqlx/query-e0d0db7115b5cdbcdeae935bd6546244efb6918d1932592321151f0bb5a8a245.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM time_entries\n WHERE org_id=$1 AND project_id=$2 AND task_id=$3 AND is_running) AS \"running!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "running!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e0d0db7115b5cdbcdeae935bd6546244efb6918d1932592321151f0bb5a8a245" +} diff --git a/.sqlx/query-f822e20275d9990bf636cfe1960a14d508fb59ed19647918c753962e0b7706ca.json b/.sqlx/query-f822e20275d9990bf636cfe1960a14d508fb59ed19647918c753962e0b7706ca.json new file mode 100644 index 00000000..97c3dc9f --- /dev/null +++ b/.sqlx/query-f822e20275d9990bf636cfe1960a14d508fb59ed19647918c753962e0b7706ca.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM project_task_members WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f822e20275d9990bf636cfe1960a14d508fb59ed19647918c753962e0b7706ca" +} diff --git a/.sqlx/query-1a8f4f77d3a261fee872d8ea657323bc9a454d75c78477921156761550fe4d6a.json b/.sqlx/query-fa78a5ad03b4e80c1f2617ddc09561a7d581f7527401499534f6d839ea5b9db7.json similarity index 61% rename from .sqlx/query-1a8f4f77d3a261fee872d8ea657323bc9a454d75c78477921156761550fe4d6a.json rename to .sqlx/query-fa78a5ad03b4e80c1f2617ddc09561a7d581f7527401499534f6d839ea5b9db7.json index 55d6f7ee..2154ae92 100644 --- a/.sqlx/query-1a8f4f77d3a261fee872d8ea657323bc9a454d75c78477921156761550fe4d6a.json +++ b/.sqlx/query-fa78a5ad03b4e80c1f2617ddc09561a7d581f7527401499534f6d839ea5b9db7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT CASE WHEN o.permission_policy_version=1 AND p.project_type='non_billable'\n THEN false ELSE t.billable_default END AS \"billable_default!\",\n p.currency, t.default_rate_currency,\n (ps.project_id IS NOT NULL) AS \"configured!\",\n (p.project_type <> 'non_billable' AND (ps.project_id IS NULL\n OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task'))) AS \"uses_task_rates!\",\n EXISTS(SELECT 1 FROM project_tasks pt\n WHERE pt.project_id = p.id AND pt.task_id = t.id) AS \"linked!\",\n CASE WHEN ps.project_id IS NULL\n OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task')\n THEN t.default_rate_cents ELSE NULL END AS default_rate_cents\n FROM projects p JOIN clients c ON c.id = p.client_id AND c.org_id = p.org_id\n JOIN organizations o ON o.id=p.org_id\n JOIN tasks t ON t.org_id = p.org_id\n LEFT JOIN project_settings ps ON ps.project_id = p.id AND ps.org_id = p.org_id\n WHERE p.id = $1 AND t.id = $2 AND p.org_id = $3\n AND p.active AND c.active AND t.active\n FOR SHARE OF c, t", + "query": "SELECT CASE WHEN o.permission_policy_version=1 AND p.project_type='non_billable'\n THEN false ELSE t.billable_default END AS \"billable_default!\",\n p.currency, t.default_rate_currency,\n (ps.project_id IS NOT NULL) AS \"configured!\",\n (p.project_type <> 'non_billable' AND (ps.project_id IS NULL\n OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task'))) AS \"uses_task_rates!\",\n EXISTS(SELECT 1 FROM project_tasks pt\n WHERE pt.project_id = p.id AND pt.task_id = t.id) AS \"linked!\",\n (o.permission_policy_version=1 AND EXISTS(SELECT 1 FROM project_tasks pt\n WHERE pt.project_id=p.id AND pt.task_id=t.id AND NOT pt.active)) AS \"archived!\",\n CASE WHEN ps.project_id IS NULL\n OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task')\n THEN t.default_rate_cents ELSE NULL END AS default_rate_cents\n FROM projects p JOIN clients c ON c.id = p.client_id AND c.org_id = p.org_id\n JOIN organizations o ON o.id=p.org_id\n JOIN tasks t ON t.org_id = p.org_id\n LEFT JOIN project_settings ps ON ps.project_id = p.id AND ps.org_id = p.org_id\n WHERE p.id = $1 AND t.id = $2 AND p.org_id = $3\n AND p.active AND c.active AND t.active\n FOR SHARE OF c, t", "describe": { "columns": [ { @@ -35,6 +35,11 @@ }, { "ordinal": 6, + "name": "archived!", + "type_info": "Bool" + }, + { + "ordinal": 7, "name": "default_rate_cents", "type_info": "Int8" } @@ -53,8 +58,9 @@ null, null, null, + null, null ] }, - "hash": "1a8f4f77d3a261fee872d8ea657323bc9a454d75c78477921156761550fe4d6a" + "hash": "fa78a5ad03b4e80c1f2617ddc09561a7d581f7527401499534f6d839ea5b9db7" } diff --git a/.sqlx/query-fabd6851e0a00c6195167f8a5fd3666a5d57278cc2a761f91cc45a4e211ccd32.json b/.sqlx/query-fabd6851e0a00c6195167f8a5fd3666a5d57278cc2a761f91cc45a4e211ccd32.json new file mode 100644 index 00000000..d1604a8d --- /dev/null +++ b/.sqlx/query-fabd6851e0a00c6195167f8a5fd3666a5d57278cc2a761f91cc45a4e211ccd32.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE tasks SET active=$2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "fabd6851e0a00c6195167f8a5fd3666a5d57278cc2a761f91cc45a4e211ccd32" +} diff --git a/.sqlx/query-fd6ba9dcee4484a978e1121cac13f61352f8f95518f042eac34ec428aeaf8090.json b/.sqlx/query-fd6ba9dcee4484a978e1121cac13f61352f8f95518f042eac34ec428aeaf8090.json new file mode 100644 index 00000000..aee0907f --- /dev/null +++ b/.sqlx/query-fd6ba9dcee4484a978e1121cac13f61352f8f95518f042eac34ec428aeaf8090.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM time_entries\n WHERE org_id=$1 AND task_id=$2 AND is_running) AS \"running!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "running!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fd6ba9dcee4484a978e1121cac13f61352f8f95518f042eac34ec428aeaf8090" +} diff --git a/.sqlx/query-fe20ee79752677eb41b5831551ce7f07d1a618963ec4671cc447f857735169f4.json b/.sqlx/query-fe20ee79752677eb41b5831551ce7f07d1a618963ec4671cc447f857735169f4.json new file mode 100644 index 00000000..f91fe439 --- /dev/null +++ b/.sqlx/query-fe20ee79752677eb41b5831551ce7f07d1a618963ec4671cc447f857735169f4.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pt.active,pt.billable,pt.rate_cents,s.budget_minutes,s.budget_cents,s.restricted\n FROM project_tasks pt JOIN project_task_settings s USING(project_id,task_id) WHERE pt.project_id=$1 AND pt.task_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "billable", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "rate_cents", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "budget_minutes", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "budget_cents", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "restricted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false + ] + }, + "hash": "fe20ee79752677eb41b5831551ce7f07d1a618963ec4671cc447f857735169f4" +} diff --git a/.sqlx/query-ffacc2fa5b292052e1cd6650b833787b1877648c17a3dabcca2e51686be867b2.json b/.sqlx/query-ffacc2fa5b292052e1cd6650b833787b1877648c17a3dabcca2e51686be867b2.json new file mode 100644 index 00000000..25cd60c2 --- /dev/null +++ b/.sqlx/query-ffacc2fa5b292052e1cd6650b833787b1877648c17a3dabcca2e51686be867b2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE project_tasks SET active=false WHERE project_id=$1 AND task_id=$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ffacc2fa5b292052e1cd6650b833787b1877648c17a3dabcca2e51686be867b2" +} diff --git a/crates/horae/migrations/0048_project_task_activity.sql b/crates/horae/migrations/0048_project_task_activity.sql new file mode 100644 index 00000000..02c7e210 --- /dev/null +++ b/crates/horae/migrations/0048_project_task_activity.sql @@ -0,0 +1,28 @@ +-- Retain configuration and history independently from global task activity. +ALTER TABLE project_tasks ADD COLUMN active boolean NOT NULL DEFAULT true; + +-- Canonical accounts already archived globally must not regain project access +-- merely by restoring the catalog task. Policy-zero semantics remain unchanged. +UPDATE project_tasks pt SET active = false +FROM tasks t, projects p, organizations o +WHERE pt.task_id = t.id AND pt.project_id = p.id AND t.org_id = p.org_id + AND o.id = p.org_id AND o.permission_policy_version = 1 AND NOT t.active; + +CREATE OR REPLACE VIEW time_entry_contexts AS +SELECT u.id AS user_id, u.org_id, p.id AS project_id, t.id AS task_id, + (p.project_type <> 'non_billable' AND pt.billable) AS billable +FROM users u +JOIN organizations o ON o.id = u.org_id +JOIN projects p ON p.org_id = u.org_id +JOIN clients c ON c.id = p.client_id AND c.org_id = u.org_id +JOIN project_tasks pt ON pt.project_id = p.id +JOIN tasks t ON t.id = pt.task_id AND t.org_id = u.org_id +LEFT JOIN assignments a ON a.project_id = p.id AND a.user_id = u.id +LEFT JOIN project_task_settings s ON s.project_id = p.id AND s.task_id = t.id +WHERE u.active AND c.active AND p.active AND t.active + AND (o.permission_policy_version = 0 OR (o.permission_policy_version = 1 AND pt.active)) + AND (u.org_role = 'admin' OR a.id IS NOT NULL) + AND (NOT COALESCE(s.restricted, false) OR EXISTS ( + SELECT 1 FROM project_task_members m + WHERE m.project_id = p.id AND m.task_id = t.id AND m.user_id = u.id + )); diff --git a/crates/horae/src/importers/harvest.rs b/crates/horae/src/importers/harvest.rs index 03ec42b3..8ddf811b 100644 --- a/crates/horae/src/importers/harvest.rs +++ b/crates/horae/src/importers/harvest.rs @@ -31,8 +31,6 @@ use chrono::Utc; #[cfg(test)] use horae_core::importers::harvest::types::SourceKind; use horae_core::importers::harvest::types::{EntityType, ImportMode, SourceRow, SyncScope}; -#[cfg(test)] -use sqlx::Acquire; use sqlx::{Connection, PgPool}; use uuid::Uuid; @@ -44,6 +42,17 @@ use resolve::{OrgDefaults, RunCache}; use crate::config::HarvestConfig; use crate::jobs::JobLease; +async fn begin_import_transaction( + connection: &mut sqlx::PgConnection, + org_id: Uuid, +) -> anyhow::Result> { + let mut tx = connection.begin().await?; + // Access-changing commands lock project parents before task links. Join + // their gate before the importer acquires any of those resource locks. + crate::db::lock_organization(&mut tx, org_id, crate::db::OrganizationLock::Shared).await?; + Ok(tx) +} + pub(crate) fn job_report(report: &ImportReport) -> anyhow::Result<(serde_json::Value, i64)> { let processed = report.summary.clients.processed() + report.summary.projects.processed() @@ -98,7 +107,7 @@ pub async fn run_import( }; let mut connection = lock_import(pool, org_id).await?; let result = async { - let mut tx = connection.begin().await?; + let mut tx = begin_import_transaction(&mut connection, org_id).await?; let mut report = ImportReport::new(source, mode); apply_rows(&mut tx, &mut RunCache::default(), &mut report, org, src).await?; match mode { diff --git a/crates/horae/src/importers/harvest/csv_source/upload.rs b/crates/horae/src/importers/harvest/csv_source/upload.rs index b6a4bea0..f9add486 100644 --- a/crates/horae/src/importers/harvest/csv_source/upload.rs +++ b/crates/horae/src/importers/harvest/csv_source/upload.rs @@ -8,12 +8,11 @@ use std::sync::Arc; use anyhow::Context; use axum::body::{Body, Bytes, HttpBody}; use horae_core::importers::harvest::types::{EntityType, ImportMode, RowOutcome, SourceKind}; -use sqlx::Acquire; use tokio::sync::{Mutex, mpsc}; use uuid::Uuid; use super::super::{ - apply, finish_import, lock_import, release_import, + apply, begin_import_transaction, finish_import, lock_import, release_import, report::ImportReport, resolve::{OrgDefaults, RunCache, preview::ParentSnapshot}, }; @@ -178,7 +177,7 @@ pub(crate) async fn import_body_with_lease( let mut batch = read_batch(&mut receive, lease.is_some()).await?; let first = batch.pop_front().ok_or(IncompleteUpload)?; let mut connection = session.lock().await; - let mut tx = connection.begin().await?; + let mut tx = begin_import_transaction(&mut connection, org_id).await?; if let Some(preview) = &checkpoint.preview { preview.restore(&mut tx, org_id).await?; } @@ -218,7 +217,7 @@ pub(crate) async fn import_body_with_lease( checkpoint.preview = Some(ParentSnapshot::capture(&mut tx, org_id, &checkpoint.cache).await?); tx.rollback().await?; - tx = connection.begin().await?; + tx = begin_import_transaction(&mut connection, org_id).await?; } lease .archive_report(&mut tx, &mut checkpoint.report) @@ -238,7 +237,7 @@ pub(crate) async fn import_body_with_lease( .await?; tx.commit().await?; batch = read_batch(&mut receive, true).await?; - tx = connection.begin().await?; + tx = begin_import_transaction(&mut connection, org_id).await?; if let Some(preview) = &checkpoint.preview { preview.restore(&mut tx, org_id).await?; } @@ -255,7 +254,7 @@ pub(crate) async fn import_body_with_lease( if mode == ImportMode::DryRun && let Some(lease) = lease { - let mut tx = connection.begin().await?; + let mut tx = begin_import_transaction(&mut connection, org_id).await?; lease .archive_report(&mut tx, &mut checkpoint.report) .await?; diff --git a/crates/horae/src/importers/harvest/engine_tests.rs b/crates/horae/src/importers/harvest/engine_tests.rs index f19cf845..5a48b270 100644 --- a/crates/horae/src/importers/harvest/engine_tests.rs +++ b/crates/horae/src/importers/harvest/engine_tests.rs @@ -17,6 +17,76 @@ use super::{RowSource, VecSource, run_import}; mod csv_streaming; mod lookup_cache; +#[sqlx::test] +async fn imported_project_task_activity_preserves_archival_and_legacy_links(pool: PgPool) { + for policy in [0, 1] { + let ids = + crate::server_fns::test_seed::seed(&pool, horae_core::types::OrgRole::Admin).await; + sqlx::query!( + "UPDATE organizations SET permission_policy_version=$2 WHERE id=$1", + ids.org_id, + policy + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!("UPDATE tasks SET active=false WHERE id=$1", ids.task_id) + .execute(&pool) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let row = nk_row( + "Acme", + "Widget", + "Dev", + "dev@example.com", + (2026, 1, 15), + "1", + None, + ); + let cache = super::resolve::RunCache::default(); + super::resolve::ensure_project_task(&mut tx, &cache, ids.project_id, ids.task_id, &row) + .await + .unwrap(); + let active = sqlx::query_scalar!( + "SELECT active FROM project_tasks WHERE project_id=$1 AND task_id=$2", + ids.project_id, + ids.task_id + ) + .fetch_one(&mut *tx) + .await + .unwrap(); + assert_eq!( + active, + policy == 0, + "canonical imports must retain global archival" + ); + sqlx::query!("UPDATE tasks SET active=true WHERE id=$1", ids.task_id) + .execute(&mut *tx) + .await + .unwrap(); + sqlx::query!("UPDATE project_tasks SET active=false, rate_cents=2500 WHERE project_id=$1 AND task_id=$2", ids.project_id, ids.task_id) + .execute(&mut *tx).await.unwrap(); + super::resolve::ensure_project_task(&mut tx, &cache, ids.project_id, ids.task_id, &row) + .await + .unwrap(); + let link = sqlx::query!( + "SELECT active, rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", + ids.project_id, + ids.task_id + ) + .fetch_one(&mut *tx) + .await + .unwrap(); + assert!( + !link.active, + "reimport is not an explicit project restoration" + ); + assert_eq!(link.rate_cents, Some(2500)); + tx.rollback().await.unwrap(); + } +} + pub(super) async fn wait_for_session_release(pool: &PgPool, org: Uuid) { use super::{ApiImportError, lock_import, release_import}; diff --git a/crates/horae/src/importers/harvest/resolve.rs b/crates/horae/src/importers/harvest/resolve.rs index ce63e2ad..363c4d8d 100644 --- a/crates/horae/src/importers/harvest/resolve.rs +++ b/crates/horae/src/importers/harvest/resolve.rs @@ -513,8 +513,8 @@ pub async fn resolve_task( Ok(Resolved::created(ParentKind::Task, ck, id)) } -/// Ensure the task is enabled on the project (FR-009). Not counted in the summary -/// — it is a link, not one of the four entity levels. +/// Ensure a retained project link exists (FR-009), without restoring archived +/// links. Not counted in the summary — it is not one of the four entity levels. pub async fn ensure_project_task( conn: &mut sqlx::PgConnection, cache: &RunCache, @@ -555,8 +555,12 @@ pub async fn ensure_project_task( )); } sqlx::query!( - "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents) - VALUES ($1, $2, $3, $4) + "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents, active) + VALUES ($1, $2, $3, $4, ( + SELECT o.permission_policy_version=0 OR (o.permission_policy_version=1 AND t.active) + FROM tasks t JOIN projects p ON p.org_id=t.org_id + JOIN organizations o ON o.id=p.org_id WHERE p.id=$1 AND t.id=$2 + )) ON CONFLICT (project_id, task_id) DO NOTHING", project_id, task_id, diff --git a/crates/horae/src/importers/harvest/resolve/preview.rs b/crates/horae/src/importers/harvest/resolve/preview.rs index 09a94a81..6b81f804 100644 --- a/crates/horae/src/importers/harvest/resolve/preview.rs +++ b/crates/horae/src/importers/harvest/resolve/preview.rs @@ -113,9 +113,16 @@ impl ParentSnapshot { ) .execute(&mut *conn) .await?; + // Checkpoints predating link activity use the same policy-dependent + // initialization as a new imported link; explicit archived state survives. sqlx::query!( - "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents) - SELECT r.project_id, r.task_id, r.billable, r.rate_cents + "INSERT INTO project_tasks (project_id, task_id, billable, rate_cents, active) + SELECT r.project_id, r.task_id, r.billable, r.rate_cents, + COALESCE(r.active, ( + SELECT o.permission_policy_version=0 OR (o.permission_policy_version=1 AND t.active) + FROM tasks t JOIN organizations o ON o.id=t.org_id + WHERE t.id=r.task_id AND o.id=$2 + )) FROM jsonb_populate_recordset(NULL::project_tasks, $1) r WHERE (SELECT org_id FROM projects WHERE id = r.project_id) = $2 AND (SELECT org_id FROM tasks WHERE id = r.task_id) = $2 @@ -133,6 +140,57 @@ impl ParentSnapshot { mod tests { use super::*; + #[sqlx::test] + async fn checkpoint_retains_link_activity_and_derives_missing_legacy_field(pool: sqlx::PgPool) { + let ids = + crate::server_fns::test_seed::seed(&pool, horae_core::types::OrgRole::Admin).await; + for policy in [0, 1] { + sqlx::query!( + "UPDATE organizations SET permission_policy_version=$2 WHERE id=$1", + ids.org_id, + policy + ) + .execute(&pool) + .await + .unwrap(); + for global_active in [false, true] { + sqlx::query!( + "UPDATE tasks SET active=$2 WHERE id=$1", + ids.task_id, + global_active + ) + .execute(&pool) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + sqlx::query!("INSERT INTO project_tasks (project_id,task_id,billable,rate_cents,active) VALUES ($1,$2,true,2500,false)", ids.project_id, ids.task_id) + .execute(&mut *tx).await.unwrap(); + let mut cache = RunCache::default(); + cache.project_tasks.insert((ids.project_id, ids.task_id)); + let mut snapshot = ParentSnapshot::capture(&mut tx, ids.org_id, &cache) + .await + .unwrap(); + tx.rollback().await.unwrap(); + for has_activity in [true, false] { + if !has_activity { + snapshot.project_tasks[0] + .as_object_mut() + .unwrap() + .remove("active"); + } + let mut tx = pool.begin().await.unwrap(); + snapshot.restore(&mut tx, ids.org_id).await.unwrap(); + let link = sqlx::query!("SELECT active, billable, rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", ids.project_id, ids.task_id) + .fetch_one(&mut *tx).await.unwrap(); + assert_eq!(link.active, !has_activity && (policy == 0 || global_active)); + assert!(link.billable); + assert_eq!(link.rate_cents, Some(2500)); + tx.rollback().await.unwrap(); + } + } + } + } + #[sqlx::test] async fn checkpoint_restores_known_task_currency_and_keeps_old_currency_unknown( pool: sqlx::PgPool, diff --git a/crates/horae/src/importers/harvest/streaming.rs b/crates/horae/src/importers/harvest/streaming.rs index 0455beeb..42090730 100644 --- a/crates/horae/src/importers/harvest/streaming.rs +++ b/crates/horae/src/importers/harvest/streaming.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::{Context, bail}; use chrono::{DateTime, Utc}; use horae_core::importers::harvest::types::{EntityType, ImportMode, SourceKind}; -use sqlx::{Acquire, PgConnection, Postgres, Transaction, pool::PoolConnection}; +use sqlx::{PgConnection, Postgres, Transaction, pool::PoolConnection}; use tokio::sync::{Mutex, mpsc}; use uuid::Uuid; @@ -221,7 +221,7 @@ async fn apply( let Some(Page::Catalog(catalog)) = pages.recv().await else { return Err(IncompleteDownload.into()); }; - let mut tx = begin_transaction(connection).await?; + let mut tx = begin_transaction(connection, org_id).await?; let org = OrgDefaults { org_id, default_currency: currency, @@ -275,8 +275,9 @@ async fn apply( async fn begin_transaction( connection: &mut PgConnection, + org_id: Uuid, ) -> anyhow::Result> { - let mut tx = connection.begin().await?; + let mut tx = super::begin_import_transaction(connection, org_id).await?; // Fresh imports grow tables inside one transaction, before autovacuum can // analyze those rows. A plan cached against the initial tiny table can scan every // provenance row for each lookup. Re-plan only inside this import transaction. @@ -290,8 +291,59 @@ async fn begin_transaction( mod tests { use super::*; + #[sqlx::test(migrations = "./migrations")] + async fn import_batches_join_the_access_gate_before_resource_locks(pool: sqlx::PgPool) { + let ids = + crate::server_fns::test_seed::seed(&pool, horae_core::types::OrgRole::Admin).await; + let mut connection = pool.acquire().await.unwrap(); + let tx = begin_transaction(&mut connection, ids.org_id) + .await + .unwrap(); + let mut access = pool.begin().await.unwrap(); + let error = sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *access) + .await + .unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("55P03") + ); + access.rollback().await.unwrap(); + tx.commit().await.unwrap(); + + let mut access = pool.begin().await.unwrap(); + crate::db::lock_organization( + &mut access, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *access) + .await + .unwrap() + .unwrap(); + let pending_pool = pool.clone(); + let pending = tokio::spawn(async move { + let mut connection = pending_pool.acquire().await.unwrap(); + let tx = begin_transaction(&mut connection, ids.org_id) + .await + .unwrap(); + tx.commit().await.unwrap(); + }); + crate::server_fns::test_seed::wait_for_blocked(&pool, holder).await; + access.commit().await.unwrap(); + pending.await.unwrap(); + } + #[sqlx::test(migrations = "./migrations")] async fn api_planning_policy_ends_with_the_transaction(pool: sqlx::PgPool) { + let ids = + crate::server_fns::test_seed::seed(&pool, horae_core::types::OrgRole::Admin).await; let mut connection = pool.acquire().await.unwrap(); connection.close_on_drop(); sqlx::query!("SET plan_cache_mode = force_generic_plan") @@ -299,7 +351,9 @@ mod tests { .await .unwrap(); for mode in [ImportMode::Commit, ImportMode::DryRun] { - let mut tx = begin_transaction(&mut connection).await.unwrap(); + let mut tx = begin_transaction(&mut connection, ids.org_id) + .await + .unwrap(); let during = sqlx::query_scalar!(r#"SELECT current_setting('plan_cache_mode') AS "mode!""#) .fetch_one(&mut *tx) diff --git a/crates/horae/src/importers/harvest/streaming/durable.rs b/crates/horae/src/importers/harvest/streaming/durable.rs index 4560d720..92fdc5af 100644 --- a/crates/horae/src/importers/harvest/streaming/durable.rs +++ b/crates/horae/src/importers/harvest/streaming/durable.rs @@ -332,7 +332,7 @@ pub(super) async fn apply( Rows::Entries(_) => bail!("API entries preceded catalog completion"), } checkpoint.download = batch.next; - let mut tx = begin_transaction(connection).await?; + let mut tx = begin_transaction(connection, org_id).await?; checkpoint .save( &mut tx, @@ -351,7 +351,7 @@ pub(super) async fn apply( .parent_offset .saturating_add(PARENT_BATCH) .min(checkpoint.parent_count()); - let mut tx = begin_transaction(connection).await?; + let mut tx = begin_transaction(connection, org_id).await?; let mut batch = tx.begin().await?; if let Some(preview) = &checkpoint.preview { preview.restore(&mut batch, org_id, &[]).await?; @@ -405,7 +405,7 @@ pub(super) async fn apply( checkpoint.missing_timestamp = true; } } - let mut tx = begin_transaction(connection).await?; + let mut tx = begin_transaction(connection, org_id).await?; let mut simulation = tx.begin().await?; if let Some(preview) = &checkpoint.preview { preview.restore(&mut simulation, org_id, &page_ids).await?; @@ -443,7 +443,7 @@ pub(super) async fn apply( _ => bail!("unexpected API checkpoint message"), } } - let mut tx = begin_transaction(connection).await?; + let mut tx = begin_transaction(connection, org_id).await?; if checkpoint.report.mode == ImportMode::Commit && checkpoint.report.error_count() == 0 && !checkpoint.missing_timestamp diff --git a/crates/horae/src/models/project_creation.rs b/crates/horae/src/models/project_creation.rs index aa6050da..88d4e18f 100644 --- a/crates/horae/src/models/project_creation.rs +++ b/crates/horae/src/models/project_creation.rs @@ -221,6 +221,8 @@ pub struct EditableProject { pub client: CreationClient, pub selection: CreationSelection, pub inactive_task_ids: Vec, + #[serde(default)] + pub archived_task_ids: Vec, pub inactive_user_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub access: Option, @@ -261,6 +263,16 @@ pub struct ProjectEditRequest { /// Every other protected field is an explicit edit, including empty or zero. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unchanged: Vec, + /// Only explicitly named links change activity; omission preserves it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub task_activity: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectTaskActivity { + pub task_id: Uuid, + pub active: bool, } /// Complete delegation intent, independent of tracking membership. diff --git a/crates/horae/src/pages/new_project.rs b/crates/horae/src/pages/new_project.rs index 27d40560..0c47528a 100644 --- a/crates/horae/src/pages/new_project.rs +++ b/crates/horae/src/pages/new_project.rs @@ -424,6 +424,7 @@ fn ProjectEditor( let request = pending_edit .write() .get_or_insert_with(|| ProjectEditRequest { + task_activity: Vec::new(), id: Uuid::now_v7(), project_id: project.id, expected_revision: project.revision, diff --git a/crates/horae/src/server_fns/importers/authorization_tests.rs b/crates/horae/src/server_fns/importers/authorization_tests.rs index 305174dd..c54a0a7a 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests.rs @@ -41,6 +41,7 @@ mod project_reads; mod scoped_directory; mod scoped_time; mod session_identity; +mod task_activity; mod task_edits; mod task_reads; mod task_writes; @@ -390,6 +391,7 @@ async fn job_endpoints_enforce_session_role_and_organization(pool: PgPool) { task_reads::check(&pool, &api).await; task_writes::check(&pool, &api).await; task_edits::check(&pool, &api).await; + task_activity::check(&pool, &api).await; scoped_directory::check(&pool, &api).await; scoped_time::check(&pool, &api).await; delegated_time::check(&pool, &api).await; diff --git a/crates/horae/src/server_fns/importers/authorization_tests/project_editor.rs b/crates/horae/src/server_fns/importers/authorization_tests/project_editor.rs index e134db67..626fc228 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests/project_editor.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests/project_editor.rs @@ -118,6 +118,7 @@ pub(super) async fn check(pool: &PgPool, api: &Api) { StatusCode::FORBIDDEN ); let mut request = ProjectEditRequest { + task_activity: Vec::new(), id: Uuid::now_v7(), project_id: initial.id, expected_revision: initial.revision, diff --git a/crates/horae/src/server_fns/importers/authorization_tests/task_activity.rs b/crates/horae/src/server_fns/importers/authorization_tests/task_activity.rs new file mode 100644 index 00000000..31c99e6b --- /dev/null +++ b/crates/horae/src/server_fns/importers/authorization_tests/task_activity.rs @@ -0,0 +1,328 @@ +//! Current task lifecycle authority through the registered session endpoint. + +use super::*; +use crate::models::project_creation::{ + EditableProject, ProjectEditRequest, ProjectTaskActivity, ProtectedProjectField, +}; +use horae_core::permissions::catalog::{Permission, PermissionSelection}; + +pub(super) async fn check(pool: &PgPool, api: &Api) { + let mut failures = Vec::new(); + for (case, role, grants, running, expected) in [ + ( + "member_archives", + OrgRole::Member, + vec![Permission::TaskWriteAll], + false, + StatusCode::OK, + ), + ( + "legacy_admin_is_not_authority", + OrgRole::Admin, + vec![], + false, + StatusCode::FORBIDDEN, + ), + ( + "archive_hides_rate", + OrgRole::Admin, + vec![Permission::TaskWriteAll], + false, + StatusCode::OK, + ), + ( + "running_timer_blocks_archive", + OrgRole::Admin, + vec![Permission::TaskWriteAll], + true, + StatusCode::CONFLICT, + ), + ( + "rate_reader_may_see_rate", + OrgRole::Member, + vec![Permission::TaskWriteAll, Permission::BillableRateReadAll], + false, + StatusCode::OK, + ), + ] { + let ids = crate::server_fns::test_seed::seed(pool, role).await; + let grants = PermissionSelection::new(&grants); + let may_read_rate = grants.contains(Permission::BillableRateReadAll); + let stored: Vec = serde_json::from_value(json!(grants)).unwrap(); + sqlx::query!("INSERT INTO person_permission_states (id,org_id,user_id,catalog_version,grants,is_administrator,source) + VALUES ($1,$2,$3,1,$4,false,'individual')", Uuid::now_v7(), ids.org_id, ids.user_id, &stored) + .execute(pool).await.unwrap(); + sqlx::query!( + "UPDATE organizations SET permission_policy_version=1 WHERE id=$1", + ids.org_id + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!( + "UPDATE tasks SET default_rate_cents=8000,default_rate_currency='EUR' WHERE id=$1", + ids.task_id + ) + .execute(pool) + .await + .unwrap(); + if running { + let entry = crate::server_fns::test_seed::time_entry( + pool, + &ids, + horae_core::types::EntryState::Open, + ) + .await; + sqlx::query!( + "UPDATE time_entries SET is_running=true,started_at=now() WHERE id=$1", + entry + ) + .execute(pool) + .await + .unwrap(); + } + let cookie = api.cookie(ids.user_id).await; + let request = json!({"task_id":ids.task_id,"active":false, + "expected_requester":{"org_id":ids.org_id,"user_id":ids.user_id}}); + if case == "member_archives" { + assert_eq!( + api.call("set_task_active", request.clone(), None, false) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + let mut mismatch = request.clone(); + mismatch["expected_requester"]["user_id"] = json!(Uuid::now_v7()); + assert_eq!( + api.call("set_task_active", mismatch, Some(&cookie), false) + .await + .status(), + StatusCode::FORBIDDEN + ); + let foreign = crate::server_fns::test_seed::seed(pool, OrgRole::Admin).await; + let mut foreign_request = request.clone(); + foreign_request["task_id"] = json!(foreign.task_id); + assert_eq!( + api.call("set_task_active", foreign_request, Some(&cookie), false) + .await + .status(), + StatusCode::NOT_FOUND + ); + + // Exercise real timer delivery on both sides of successful archival. + sqlx::query!( + "INSERT INTO project_tasks (project_id,task_id,billable) VALUES ($1,$2,true)", + ids.project_id, + ids.task_id + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!( + "INSERT INTO assignments (id,project_id,user_id) VALUES ($1,$2,$3)", + Uuid::now_v7(), + ids.project_id, + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + let timer = api + .call( + "start_timer", + json!({"project_id":ids.project_id,"task_id":ids.task_id,"notes":null}), + Some(&cookie), + false, + ) + .await; + assert_eq!(timer.status(), StatusCode::OK); + let timer: Value = timer.json().await.unwrap(); + assert_eq!( + api.call("set_task_active", request.clone(), Some(&cookie), false) + .await + .status(), + StatusCode::CONFLICT + ); + assert_eq!( + api.call( + "stop_timer", + json!({"entry_id":timer["id"]}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::OK + ); + } + let response = api + .call("set_task_active", request.clone(), Some(&cookie), false) + .await; + let status = response.status(); + let body = response.text().await.unwrap(); + if status != expected { + failures.push(format!("{case}: expected {expected}, got {status}: {body}")); + } + if status == StatusCode::OK { + let value: Value = serde_json::from_str(&body).unwrap(); + let expected_rate = if may_read_rate { + Some(json!(8000)) + } else { + None + }; + if value.get("default_rate_cents").cloned() != expected_rate { + failures.push(format!("{case}: wrong rate projection: {body}")); + } + } + let active = sqlx::query_scalar!("SELECT active FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(pool) + .await + .unwrap(); + if active != (expected != StatusCode::OK) { + failures.push(format!("{case}: unexpected stored activity {active}")); + } + if case == "member_archives" { + assert_eq!( + api.call( + "start_timer", + json!({"project_id":ids.project_id,"task_id":ids.task_id,"notes":null}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::CONFLICT + ); + for active in [false, true, true] { + let mut activity_request = request.clone(); + activity_request["active"] = json!(active); + let response = api + .call("set_task_active", activity_request, Some(&cookie), false) + .await; + assert_eq!(response.status(), StatusCode::OK); + let value: Value = response.json().await.unwrap(); + assert_eq!(value["active"], json!(active)); + assert!(value.get("default_rate_cents").is_none()); + } + assert_eq!( + api.call( + "start_timer", + json!({"project_id":ids.project_id,"task_id":ids.task_id,"notes":null}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::CONFLICT + ); + assert_eq!( + api.call( + "load_project_editor", + json!({"project_id":ids.project_id}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + let stored: Vec = serde_json::from_value(json!(PermissionSelection::new(&[ + Permission::ProjectWriteAll + ]))) + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET grants=$2 WHERE user_id=$1", + ids.user_id, + &stored + ) + .execute(pool) + .await + .unwrap(); + let editor: EditableProject = serde_json::from_value( + api.json( + "load_project_editor", + json!({"project_id":ids.project_id}), + &cookie, + ) + .await, + ) + .unwrap(); + assert_eq!(editor.archived_task_ids, [ids.task_id]); + let mut unchanged = vec![ + ProtectedProjectField::ProjectRate, + ProtectedProjectField::Budget, + ProtectedProjectField::Fees, + ProtectedProjectField::InvoiceDefaults, + ProtectedProjectField::PrivateNotes, + ]; + unchanged.extend( + editor + .form + .tasks + .iter() + .map(|task| ProtectedProjectField::TaskRate(task.id)), + ); + for person in &editor.form.team { + unchanged.extend([ + ProtectedProjectField::PersonRate(person.user_id), + ProtectedProjectField::CostRate(person.user_id), + ]); + } + let restore = ProjectEditRequest { + id: Uuid::now_v7(), + project_id: editor.id, + expected_revision: editor.revision, + expected_requester: editor.access.as_ref().map(|access| access.requester), + managers: editor + .access + .as_ref() + .map(|access| (&access.managers).into()), + form: editor.form, + unchanged, + task_activity: vec![ProjectTaskActivity { + task_id: ids.task_id, + active: true, + }], + }; + for _ in 0..2 { + assert_eq!( + api.call( + "save_project_editor", + json!({"request":restore}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::OK + ); + } + let timer = api + .call( + "start_timer", + json!({"project_id":ids.project_id,"task_id":ids.task_id,"notes":null}), + Some(&cookie), + false, + ) + .await; + assert_eq!(timer.status(), StatusCode::OK); + let timer: Value = timer.json().await.unwrap(); + assert_eq!( + api.call( + "stop_timer", + json!({"entry_id":timer["id"]}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::OK + ); + } + } + assert!( + failures.is_empty(), + "Task activity contract failures:\n{}", + failures.join("\n") + ); +} diff --git a/crates/horae/src/server_fns/project_creation/editing.rs b/crates/horae/src/server_fns/project_creation/editing.rs index 1ed74a4d..5a7ecfe6 100644 --- a/crates/horae/src/server_fns/project_creation/editing.rs +++ b/crates/horae/src/server_fns/project_creation/editing.rs @@ -13,6 +13,7 @@ pub(super) mod catalog; mod projection; mod protected; mod save; +mod task_activity; fn apply_manager_flags( form: &mut ProjectForm, @@ -272,7 +273,7 @@ async fn load_project_form( // Resolve assigned identities, including archived records, independently // of the active catalog's search and pagination. They are not new choices. let tasks = sqlx::query!( - "SELECT t.id, t.name, t.active, t.billable_default, t.default_rate_cents, t.default_rate_currency + "SELECT t.id, t.name, t.active, pt.active AS link_active, t.billable_default, t.default_rate_cents, t.default_rate_currency FROM tasks t JOIN project_tasks pt ON pt.task_id = t.id WHERE pt.project_id = $1 AND t.org_id = $2 ORDER BY t.id", project_id, org_id, @@ -282,6 +283,11 @@ async fn load_project_form( .filter(|task| !task.active) .map(|task| task.id) .collect(); + let archived_task_ids = tasks + .iter() + .filter(|task| !task.link_active) + .map(|task| task.id) + .collect(); let tasks = tasks .into_iter() .map(|task| CreationTask { @@ -327,6 +333,7 @@ async fn load_project_form( client, selection: CreationSelection { tasks, people }, inactive_task_ids, + archived_task_ids, inactive_user_ids, access: None, }) diff --git a/crates/horae/src/server_fns/project_creation/editing/protected.rs b/crates/horae/src/server_fns/project_creation/editing/protected.rs index 1b94478a..eccdd7cc 100644 --- a/crates/horae/src/server_fns/project_creation/editing/protected.rs +++ b/crates/horae/src/server_fns/project_creation/editing/protected.rs @@ -35,6 +35,9 @@ impl RequiredWrites { } pub(super) fn validate_intent(request: &ProjectEditRequest) -> Result<(), ServerFnError> { + if request.task_activity.len() > request.form.tasks.len() { + return Err(err(BAD_REQUEST, "Too many task activity changes")); + } if request.unchanged.len() > 1505 { return Err(err(BAD_REQUEST, "Too many preserved project fields")); } diff --git a/crates/horae/src/server_fns/project_creation/editing/save.rs b/crates/horae/src/server_fns/project_creation/editing/save.rs index dee2a7a2..5732b217 100644 --- a/crates/horae/src/server_fns/project_creation/editing/save.rs +++ b/crates/horae/src/server_fns/project_creation/editing/save.rs @@ -61,7 +61,11 @@ pub(super) async fn save( }); } super::protected::validate_intent(request)?; - if permissions.is_none() && (!request.unchanged.is_empty() || request.managers.is_some()) { + if permissions.is_none() + && (!request.unchanged.is_empty() + || request.managers.is_some() + || !request.task_activity.is_empty()) + { return Err(err( BAD_REQUEST, "Protected-field intent requires the current permission policy", @@ -95,7 +99,7 @@ pub(super) async fn save( } else { None }; - let intent = if permissions.is_some() { + let mut intent = if permissions.is_some() { let mut unchanged = request.unchanged.clone(); unchanged.sort_unstable(); serde_json::json!({ @@ -107,6 +111,20 @@ pub(super) async fn save( } else { raw_payload }; + if !request.task_activity.is_empty() { + let mut activity = request.task_activity.clone(); + activity.sort_by_key(|edit| edit.task_id); + if activity + .windows(2) + .any(|pair| pair[0].task_id == pair[1].task_id) + { + return Err(err( + BAD_REQUEST, + "A task activity change may only be selected once", + )); + } + intent["task_activity"] = serde_json::to_value(activity).map_err(server_err)?; + } let revision = sqlx::query_scalar!( "SELECT edit_revision FROM projects WHERE id = $1 AND org_id = $2 FOR UPDATE", request.project_id, @@ -323,6 +341,7 @@ pub(super) async fn save( )); } let parsed = validate_edit(&before, form, email_available)?; + super::task_activity::apply(&mut tx, org_id, &before, form, &request.task_activity).await?; let reset_task_default = before.configured && access.is_some() && (form.rate_mode == RateMode::Legacy diff --git a/crates/horae/src/server_fns/project_creation/editing/task_activity.rs b/crates/horae/src/server_fns/project_creation/editing/task_activity.rs new file mode 100644 index 00000000..26615fa7 --- /dev/null +++ b/crates/horae/src/server_fns/project_creation/editing/task_activity.rs @@ -0,0 +1,66 @@ +//! Explicit project-link lifecycle changes within the existing editor transaction. + +use super::*; +use crate::models::project_creation::ProjectTaskActivity; + +pub(super) async fn apply( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + org_id: Uuid, + before: &EditableProject, + form: &ProjectForm, + edits: &[ProjectTaskActivity], +) -> Result<(), ServerFnError> { + // The editor already holds current project-write authority, the organization + // access gate and the parent project lock. Financial fields are independent. + let mut edits: Vec<_> = edits.iter().collect(); + edits.sort_by_key(|edit| edit.task_id); + for edit in edits { + let source = TaskSource::Existing { + task_id: edit.task_id, + }; + if !before.form.tasks.iter().any(|task| task.source == source) + || !form.tasks.iter().any(|task| task.source == source) + { + return Err(err( + BAD_REQUEST, + "Activity changes require a retained project task", + )); + } + let task_active = sqlx::query_scalar!( + "SELECT active FROM tasks WHERE id=$1 AND org_id=$2 FOR SHARE", + edit.task_id, + org_id, + ) + .fetch_optional(&mut **tx) + .await + .map_err(storage_error)? + .ok_or_else(|| not_found("Task not found"))?; + if edit.active && !task_active { + return Err(conflict( + "Restore the task in the task catalog before restoring it on this project", + )); + } + if !edit.active { + let running = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM time_entries + WHERE org_id=$1 AND project_id=$2 AND task_id=$3 AND is_running) AS "running!""#, + org_id, + before.id, + edit.task_id, + ) + .fetch_one(&mut **tx) + .await + .map_err(storage_error)?; + if running { + return Err(conflict( + "A running timer prevents archiving this project task", + )); + } + } + sqlx::query!( + "UPDATE project_tasks SET active=$3 WHERE project_id=$1 AND task_id=$2 AND active IS DISTINCT FROM $3", + before.id, edit.task_id, edit.active, + ).execute(&mut **tx).await.map_err(storage_error)?; + } + Ok(()) +} diff --git a/crates/horae/src/server_fns/project_creation/editing/tests.rs b/crates/horae/src/server_fns/project_creation/editing/tests.rs index 19ef5d06..d077eb6a 100644 --- a/crates/horae/src/server_fns/project_creation/editing/tests.rs +++ b/crates/horae/src/server_fns/project_creation/editing/tests.rs @@ -11,6 +11,7 @@ mod canonical_concurrency; mod canonical_fields; mod canonical_managers; mod catalog; +mod task_activity; #[sqlx::test(migrations = "./migrations")] #[serial_test::serial] @@ -109,6 +110,7 @@ async fn editor_load_uses_current_role_across_all_billing_configurations(pool: P fn edit_request(project: EditableProject) -> ProjectEditRequest { ProjectEditRequest { + task_activity: Vec::new(), id: Uuid::now_v7(), project_id: project.id, expected_revision: project.revision, diff --git a/crates/horae/src/server_fns/project_creation/editing/tests/task_activity.rs b/crates/horae/src/server_fns/project_creation/editing/tests/task_activity.rs new file mode 100644 index 00000000..2e3e397c --- /dev/null +++ b/crates/horae/src/server_fns/project_creation/editing/tests/task_activity.rs @@ -0,0 +1,229 @@ +use super::canonical_fields::{canonical_actor, keep_protected}; +use super::*; +use crate::models::project_creation::ProjectTaskActivity; +use crate::server_fns::test_seed::{SeedIds, time_entry}; +use horae_core::permissions::catalog::{Permission, PermissionSelection}; + +async fn fixture(pool: &PgPool) -> (SeedIds, EditableProject) { + let (owner, project) = configured_fixture(pool).await; + canonical_actor( + pool, + &owner, + &PermissionSelection::new(&[Permission::ProjectWriteAll]), + ) + .await; + let project = load_editable_project(pool, owner.user_id, owner.org_id, project.id) + .await + .unwrap(); + (owner, project) +} + +fn request(project: EditableProject, task: Uuid, active: bool) -> ProjectEditRequest { + let mut request = edit_request(project); + request.unchanged = keep_protected(&request.form); + request.task_activity.push(ProjectTaskActivity { + task_id: task, + active, + }); + request +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_task_archive_restore_preserves_hidden_configuration_history_and_replays( + pool: PgPool, +) { + let (owner, original) = fixture(&pool).await; + let history = time_entry( + &pool, + &SeedIds { + project_id: original.id, + ..owner + }, + EntryState::Open, + ) + .await; + sqlx::query!("INSERT INTO project_task_members (id,org_id,project_id,task_id,user_id) VALUES ($1,$2,$3,$4,$5)", Uuid::now_v7(), owner.org_id, original.id, owner.task_id, owner.user_id) + .execute(&pool).await.unwrap(); + for (budget_minutes, budget_cents) in [(Some(120), None), (None, Some(3000))] { + sqlx::query!("UPDATE project_task_settings SET budget_minutes=$3,budget_cents=$4,restricted=true WHERE project_id=$1 AND task_id=$2", original.id, owner.task_id, budget_minutes, budget_cents) + .execute(&pool).await.unwrap(); + for active in [false, true] { + let project = load_editable_project(&pool, owner.user_id, owner.org_id, original.id) + .await + .unwrap(); + assert!( + project.form.tasks[0].rate.is_empty(), + "rate is withheld from this editor" + ); + let request = request(project, owner.task_id, active); + for changed in [true, false] { + let (_, actual) = + save_editable_project(&pool, owner.user_id, owner.org_id, &request, false) + .await + .unwrap(); + assert_eq!(actual, changed); + } + let after = load_editable_project(&pool, owner.user_id, owner.org_id, original.id) + .await + .unwrap(); + assert_eq!(after.archived_task_ids.contains(&owner.task_id), !active); + assert!(after.inactive_task_ids.is_empty()); + let saved = sqlx::query!("SELECT pt.active,pt.billable,pt.rate_cents,s.budget_minutes,s.budget_cents,s.restricted + FROM project_tasks pt JOIN project_task_settings s USING(project_id,task_id) WHERE pt.project_id=$1 AND pt.task_id=$2", original.id, owner.task_id) + .fetch_one(&pool).await.unwrap(); + assert_eq!( + ( + saved.active, + saved.billable, + saved.rate_cents, + saved.budget_minutes, + saved.budget_cents, + saved.restricted + ), + (active, true, Some(2500), budget_minutes, budget_cents, true) + ); + assert_eq!( + sqlx::query_scalar!("SELECT minutes FROM time_entries WHERE id=$1", history) + .fetch_one(&pool) + .await + .unwrap(), + 60 + ); + assert_eq!( + sqlx::query_scalar!( + "SELECT count(*) FROM project_task_members WHERE project_id=$1 AND task_id=$2", + original.id, + owner.task_id + ) + .fetch_one(&pool) + .await + .unwrap(), + Some(1) + ); + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_settings_save_without_activity_intent_does_not_restore_archived_links( + pool: PgPool, +) { + let (owner, project) = fixture(&pool).await; + let archive = request(project, owner.task_id, false); + save_editable_project(&pool, owner.user_id, owner.org_id, &archive, false) + .await + .unwrap(); + let project = load_editable_project(&pool, owner.user_id, owner.org_id, archive.project_id) + .await + .unwrap(); + let mut rename = edit_request(project); + rename.form.name = "Unrelated project edit".into(); + rename.unchanged = keep_protected(&rename.form); + let encoded = serde_json::to_value(&rename).unwrap(); + assert!(encoded.get("task_activity").is_none()); + let decoded: ProjectEditRequest = serde_json::from_value(encoded).unwrap(); + save_editable_project(&pool, owner.user_id, owner.org_id, &decoded, false) + .await + .unwrap(); + assert!( + !sqlx::query_scalar!( + "SELECT active FROM project_tasks WHERE project_id=$1 AND task_id=$2", + rename.project_id, + owner.task_id + ) + .fetch_one(&pool) + .await + .unwrap() + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn invalid_task_activity_targets_and_global_restore_order_rollback_the_whole_edit( + pool: PgPool, +) { + let (owner, project) = fixture(&pool).await; + let foreign = seed(&pool, OrgRole::Admin).await; + for case in [ + "foreign", + "removed", + "duplicate", + "global_archived", + "running", + ] { + let current = load_editable_project(&pool, owner.user_id, owner.org_id, project.id) + .await + .unwrap(); + let mut edit = request(current, owner.task_id, case == "global_archived"); + edit.form.name = "Must roll back".into(); + match case { + "foreign" => edit.task_activity[0].task_id = foreign.task_id, + "removed" => { + edit.form.tasks.clear(); + edit.unchanged = keep_protected(&edit.form); + } + "duplicate" => edit.task_activity.push(edit.task_activity[0].clone()), + "global_archived" => { + sqlx::query!("UPDATE tasks SET active=false WHERE id=$1", owner.task_id) + .execute(&pool) + .await + .unwrap(); + } + "running" => { + sqlx::query!("UPDATE tasks SET active=true WHERE id=$1", owner.task_id) + .execute(&pool) + .await + .unwrap(); + let entry = time_entry( + &pool, + &SeedIds { + project_id: project.id, + ..owner + }, + EntryState::Open, + ) + .await; + sqlx::query!( + "UPDATE time_entries SET is_running=true,started_at=now() WHERE id=$1", + entry + ) + .execute(&pool) + .await + .unwrap(); + } + _ => unreachable!(), + } + let error = save_editable_project(&pool, owner.user_id, owner.org_id, &edit, false) + .await + .unwrap_err(); + assert!( + matches!( + error, + ServerFnError::ServerError { + code: BAD_REQUEST | CONFLICT, + .. + } + ), + "{case}: {error:?}" + ); + assert_eq!( + sqlx::query_scalar!("SELECT name FROM projects WHERE id=$1", project.id) + .fetch_one(&pool) + .await + .unwrap(), + project.form.name + ); + assert!( + sqlx::query_scalar!( + "SELECT active FROM project_tasks WHERE project_id=$1 AND task_id=$2", + project.id, + owner.task_id + ) + .fetch_one(&pool) + .await + .unwrap() + ); + } +} diff --git a/crates/horae/src/server_fns/project_creation/locking_tests.rs b/crates/horae/src/server_fns/project_creation/locking_tests.rs index 4da32419..354c4bff 100644 --- a/crates/horae/src/server_fns/project_creation/locking_tests.rs +++ b/crates/horae/src/server_fns/project_creation/locking_tests.rs @@ -84,6 +84,7 @@ async fn every_project_entry_gates_before_actor_and_rechecks_revocation(pool: Pg .await .unwrap(); let request = ProjectEditRequest { + task_activity: Vec::new(), id: Uuid::now_v7(), project_id: ids.project_id, expected_revision: editor.revision, diff --git a/crates/horae/src/server_fns/projects.rs b/crates/horae/src/server_fns/projects.rs index 02dc61f9..cbf0d36e 100644 --- a/crates/horae/src/server_fns/projects.rs +++ b/crates/horae/src/server_fns/projects.rs @@ -45,6 +45,9 @@ mod task_write_tests; #[cfg(all(test, feature = "server"))] mod task_edit_tests; +#[cfg(all(test, feature = "server"))] +mod task_activity_tests; + // ── Projects ───────────────────────────────────────────────────────────────── #[server] @@ -926,15 +929,11 @@ async fn authorize_task_write( org_id: uuid::Uuid, actor_id: uuid::Uuid, project_id: Option, + gate: OrganizationLock, ) -> Result, ServerFnError> { use crate::server_fns::permissions::{configure_administration, load_person_permissions}; configure_administration(tx).await.map_err(server_err)?; - let gate = if project_id.is_some() { - OrganizationLock::AccessChange - } else { - OrganizationLock::Shared - }; lock_organization(tx, org_id, gate) .await .map_err(server_err)?; @@ -994,7 +993,12 @@ async fn create_task_for_project( return Err(conflict("Task name cannot be empty")); } let mut tx = db.begin().await.map_err(server_err)?; - authorize_task_write(&mut tx, org_id, actor_id, project_id).await?; + let gate = if project_id.is_some() { + OrganizationLock::AccessChange + } else { + OrganizationLock::Shared + }; + authorize_task_write(&mut tx, org_id, actor_id, project_id, gate).await?; let id = uuid::Uuid::now_v7(); let task = sqlx::query_as!( Task, @@ -1069,7 +1073,8 @@ async fn update_task_record( return Err(conflict("Task name cannot be empty")); } let mut tx = db.begin().await.map_err(server_err)?; - let permissions = authorize_task_write(&mut tx, org_id, actor_id, None).await?; + let permissions = + authorize_task_write(&mut tx, org_id, actor_id, None, OrganizationLock::Shared).await?; let intent = match rate { TaskRateEdit::Preserve {} => RateEdit::Unchanged, TaskRateEdit::Clear {} => RateEdit::Reset, @@ -1153,31 +1158,20 @@ async fn update_task_record( /// Activate or deactivate an org-level task. Deactivated tasks are hidden from /// new-entry pickers but stay attached to existing time entries (FR-011). #[server] -pub async fn set_task_active(task_id: String, active: bool) -> Result { - let manager = require_manager().await?; +pub async fn set_task_active( + task_id: String, + active: bool, + expected_requester: PermissionRequester, +) -> Result { + let actor = require_user().await?; + project_requester(&actor, Some(expected_requester)) + .map_err(|_| forbidden("Task requester has changed"))?; let state = crate::state::global_state().await; let task_id = parse_uuid(&task_id, "task_id")?; - let (task, transition) = - set_task_active_record(&state.db, manager.org_id, task_id, active).await?; - if let Some(t) = transition { - let occurred_at = chrono::Utc::now(); - let task = task_payload(&task); - state.plugins.dispatch(match t { - crate::plugin::event::ActiveTransition::Reactivated => { - crate::plugin::AppEvent::TaskReactivated { - occurred_at, - org_id: manager.org_id, - task, - } - } - crate::plugin::event::ActiveTransition::Deactivated => { - crate::plugin::AppEvent::TaskDeactivated { - occurred_at, - org_id: manager.org_id, - task, - } - } - }); + let (task, event) = + set_task_active_record(&state.db, actor.org_id, actor.id, task_id, active).await?; + if let Some(event) = event { + state.plugins.dispatch(event); } Ok(task) } @@ -1186,12 +1180,52 @@ pub async fn set_task_active(task_id: String, active: bool) -> Result Result<(Task, Option), ServerFnError> { +) -> Result<(Task, Option), ServerFnError> { let mut tx = db.begin().await.map_err(server_err)?; + // Tracking writers hold the shared gate through entry insertion. + let permissions = authorize_task_write( + &mut tx, + org_id, + actor_id, + None, + OrganizationLock::AccessChange, + ) + .await?; + if permissions.is_some() && !active { + // Link updates invalidate editors through their parent project trigger. + // Take every parent before the task/link locks, in stable order. + sqlx::query_scalar!( + "SELECT p.id FROM projects p JOIN project_tasks pt ON pt.project_id=p.id + JOIN tasks t ON t.id=pt.task_id AND t.org_id=p.org_id + WHERE p.org_id=$1 AND t.id=$2 ORDER BY p.id FOR NO KEY UPDATE OF p", + org_id, + task_id, + ) + .fetch_all(&mut *tx) + .await + .map_err(server_err)?; + } let before = lock_task(&mut tx, org_id, task_id).await?; + if permissions.is_some() && !active { + let running = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM time_entries + WHERE org_id=$1 AND task_id=$2 AND is_running) AS "running!""#, + org_id, + task_id, + ) + .fetch_one(&mut *tx) + .await + .map_err(server_err)?; + if running { + // Do not reveal an entry or its owner to a task-only administrator. + return Err(conflict("A running timer prevents archiving this task")); + } + } + let task = sqlx::query_as!( Task, "UPDATE tasks SET active = $3 @@ -1209,8 +1243,46 @@ async fn set_task_active_record( let transition = task.as_ref().and_then(|updated| { crate::plugin::event::active_transition(Some(before.active), updated.active) }); + if permissions.is_some() && !active { + sqlx::query!( + "UPDATE project_tasks pt SET active=false FROM projects p + WHERE pt.project_id=p.id AND p.org_id=$1 AND pt.task_id=$2 AND pt.active", + org_id, + task_id, + ) + .execute(&mut *tx) + .await + .map_err(server_err)?; + } + let mut task = task.unwrap_or(before); + let event = transition.map(|transition| { + let occurred_at = chrono::Utc::now(); + let task = task_payload(&task); + match transition { + crate::plugin::event::ActiveTransition::Reactivated => { + crate::plugin::AppEvent::TaskReactivated { + occurred_at, + org_id, + task, + } + } + crate::plugin::event::ActiveTransition::Deactivated => { + crate::plugin::AppEvent::TaskDeactivated { + occurred_at, + org_id, + task, + } + } + } + }); + if permissions + .as_ref() + .is_some_and(|p| !p.grants.contains(Permission::BillableRateReadAll)) + { + task.default_rate_cents = None; + } tx.commit().await.map_err(server_err)?; - Ok((task.unwrap_or(before), transition)) + Ok((task, event)) } #[cfg(feature = "server")] @@ -1306,6 +1378,8 @@ async fn enable_project_task( OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task'))) AS "uses_task_rates!", EXISTS(SELECT 1 FROM project_tasks pt WHERE pt.project_id = p.id AND pt.task_id = t.id) AS "linked!", + (o.permission_policy_version=1 AND EXISTS(SELECT 1 FROM project_tasks pt + WHERE pt.project_id=p.id AND pt.task_id=t.id AND NOT pt.active)) AS "archived!", CASE WHEN ps.project_id IS NULL OR (p.project_type = 'time_and_materials' AND ps.rate_mode = 'task') THEN t.default_rate_cents ELSE NULL END AS default_rate_cents @@ -1325,6 +1399,11 @@ async fn enable_project_task( .map_err(server_err)? .ok_or_else(|| not_found("Active project and task not found in this organization"))?; if task.linked { + if task.archived { + return Err(conflict( + "Restore this task explicitly in the project editor", + )); + } return Ok(()); } let rate_cents = if let Some(rate) = explicit_rate { diff --git a/crates/horae/src/server_fns/projects/mutation_tests.rs b/crates/horae/src/server_fns/projects/mutation_tests.rs index 9a0d32f7..6b08a7d3 100644 --- a/crates/horae/src/server_fns/projects/mutation_tests.rs +++ b/crates/horae/src/server_fns/projects/mutation_tests.rs @@ -54,6 +54,7 @@ mod project { .await .unwrap(); ProjectEditRequest { + task_activity: Vec::new(), id: uuid::Uuid::now_v7(), project_id: project.id, expected_revision: project.revision, @@ -650,6 +651,22 @@ mod project { mod task { use super::*; + async fn activity( + pool: &PgPool, + org_id: uuid::Uuid, + actor_id: uuid::Uuid, + task_id: uuid::Uuid, + active: bool, + ) -> Result<(Task, Option), ServerFnError> { + let (task, event) = set_task_active_record(pool, org_id, actor_id, task_id, active).await?; + let transition = event.map(|event| match event { + crate::plugin::AppEvent::TaskReactivated { .. } => ActiveTransition::Reactivated, + crate::plugin::AppEvent::TaskDeactivated { .. } => ActiveTransition::Deactivated, + other => panic!("unexpected task activity event: {other:?}"), + }); + Ok((task, transition)) + } + fn rate_edit(amount: Option) -> TaskRateEdit { amount.map_or(TaskRateEdit::Clear {}, |amount_cents| TaskRateEdit::Set { amount_cents, @@ -699,7 +716,7 @@ mod task { #[sqlx::test(migrations = "./migrations")] async fn each_detail_field_changes_once_and_preserves_inactive_status(pool: PgPool) { let ids = seed(&pool, OrgRole::Admin).await; - let (mut expected, _) = set_task_active_record(&pool, ids.org_id, ids.task_id, false) + let (mut expected, _) = activity(&pool, ids.org_id, ids.user_id, ids.task_id, false) .await .unwrap(); for (name, billable, rate) in [ @@ -782,7 +799,7 @@ mod task { async fn unchanged_activation_preserves_the_row(pool: PgPool) { let ids = seed(&pool, OrgRole::Admin).await; let before = version(&pool, ids.task_id).await; - let (returned, transition) = set_task_active_record(&pool, ids.org_id, ids.task_id, true) + let (returned, transition) = activity(&pool, ids.org_id, ids.user_id, ids.task_id, true) .await .unwrap(); let after = version(&pool, ids.task_id).await; @@ -843,7 +860,7 @@ mod task { let run_pool = pool.clone(); let mut run = tokio::task::JoinSet::new(); run.spawn(async move { - set_task_active_record(&run_pool, ids.org_id, ids.task_id, active).await + activity(&run_pool, ids.org_id, ids.user_id, ids.task_id, active).await }); wait_for_blocked(pool, blocker).await; first.commit().await.unwrap(); @@ -902,7 +919,7 @@ mod task { .. } )); - let error = set_task_active_record(&pool, org_id, id, true) + let error = activity(&pool, org_id, actor_id, id, true) .await .unwrap_err(); assert!(matches!( @@ -925,12 +942,12 @@ mod task { (true, ActiveTransition::Reactivated), ] { expected.active = active; - let (updated, actual) = set_task_active_record(&pool, ids.org_id, ids.task_id, active) + let (updated, actual) = activity(&pool, ids.org_id, ids.user_id, ids.task_id, active) .await .unwrap(); assert_eq!((actual, updated), (Some(transition), expected.clone())); let before = version(&pool, ids.task_id).await; - let (updated, actual) = set_task_active_record(&pool, ids.org_id, ids.task_id, active) + let (updated, actual) = activity(&pool, ids.org_id, ids.user_id, ids.task_id, active) .await .unwrap(); let after = version(&pool, ids.task_id).await; @@ -956,7 +973,7 @@ mod task { let mut run = tokio::task::JoinSet::new(); run.spawn(async move { if activation { - set_task_active_record(&run_pool, ids.org_id, ids.task_id, true) + activity(&run_pool, ids.org_id, ids.user_id, ids.task_id, true) .await .map(|_| ()) } else { diff --git a/crates/horae/src/server_fns/projects/task_activity_tests.rs b/crates/horae/src/server_fns/projects/task_activity_tests.rs new file mode 100644 index 00000000..89827a2f --- /dev/null +++ b/crates/horae/src/server_fns/projects/task_activity_tests.rs @@ -0,0 +1,429 @@ +use super::canonical_read_tests::fixture; +use super::*; +use crate::server_fns::test_seed::{time_entry, wait_for_blocked}; +use horae_core::permissions::catalog::PermissionSelection; +use sqlx::PgPool; + +#[sqlx::test(migrations = false)] +#[serial_test::serial] +async fn project_task_activity_migration_preserves_legacy_and_archives_canonical_links( + pool: PgPool, +) { + let mut previous = sqlx::migrate!("./migrations"); + previous.migrations = std::borrow::Cow::Owned( + previous + .iter() + .filter(|migration| migration.version < 48) + .cloned() + .collect(), + ); + previous.run(&pool).await.unwrap(); + let legacy = crate::server_fns::test_seed::seed(&pool, OrgRole::Admin).await; + let (canonical, _) = fixture( + &pool, + OrgRole::Member, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + for ids in [&legacy, &canonical] { + sqlx::query!("UPDATE tasks SET active=false WHERE id=$1", ids.task_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query!("INSERT INTO project_tasks (project_id,task_id,billable,rate_cents) VALUES ($1,$2,true,1200)", ids.project_id, ids.task_id).execute(&pool).await.unwrap(); + time_entry(&pool, ids, EntryState::Open).await; + } + let legacy_revision = sqlx::query_scalar!( + "SELECT edit_revision FROM projects WHERE id=$1", + legacy.project_id + ) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + for (ids, active) in [(&legacy, true), (&canonical, false)] { + let row = sqlx::query!("SELECT active,billable,rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", ids.project_id, ids.task_id).fetch_one(&pool).await.unwrap(); + assert_eq!( + (row.active, row.billable, row.rate_cents), + (active, true, Some(1200)) + ); + assert_eq!( + sqlx::query_scalar!( + "SELECT sum(minutes)::bigint FROM time_entries WHERE org_id=$1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(), + Some(60) + ); + } + assert_eq!( + sqlx::query_scalar!( + "SELECT edit_revision FROM projects WHERE id=$1", + legacy.project_id + ) + .fetch_one(&pool) + .await + .unwrap(), + legacy_revision + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn global_restore_does_not_restore_project_tracking_and_archive_invalidates_editor( + pool: PgPool, +) { + let (ids, _) = fixture( + &pool, + OrgRole::Member, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + sqlx::query!("INSERT INTO project_tasks (project_id,task_id,billable,rate_cents) VALUES ($1,$2,true,1200)", ids.project_id, ids.task_id) + .execute(&pool).await.unwrap(); + sqlx::query!( + "INSERT INTO assignments (id,project_id,user_id) VALUES ($1,$2,$3)", + uuid::Uuid::now_v7(), + ids.project_id, + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + let revision = sqlx::query_scalar!( + "SELECT edit_revision FROM projects WHERE id=$1", + ids.project_id + ) + .fetch_one(&pool) + .await + .unwrap(); + let mut failures = Vec::new(); + for active in [false, true] { + set_task_active_record(&pool, ids.org_id, ids.user_id, ids.task_id, active) + .await + .unwrap(); + let trackable = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM time_entry_contexts WHERE user_id=$1 AND project_id=$2 AND task_id=$3)", ids.user_id, ids.project_id, ids.task_id) + .fetch_one(&pool).await.unwrap().unwrap(); + if trackable { + failures.push("Global restore implicitly restored project tracking"); + } + let current_revision = sqlx::query_scalar!( + "SELECT edit_revision FROM projects WHERE id=$1", + ids.project_id + ) + .fetch_one(&pool) + .await + .unwrap(); + if current_revision <= revision { + failures.push("Global archive did not invalidate the project editor"); + } + let saved = sqlx::query!( + "SELECT billable,rate_cents FROM project_tasks WHERE project_id=$1 AND task_id=$2", + ids.project_id, + ids.task_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((saved.billable, saved.rate_cents), (true, Some(1200))); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn activity_separates_session_rates_from_events_and_preserves_noops(pool: PgPool) { + let (ids, _) = fixture( + &pool, + OrgRole::Member, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + sqlx::query!( + "UPDATE tasks SET default_rate_cents=8000,default_rate_currency='EUR' WHERE id=$1", + ids.task_id + ) + .execute(&pool) + .await + .unwrap(); + let entry = time_entry(&pool, &ids, EntryState::Open).await; + for (active, changed) in [(false, true), (false, false), (true, true), (true, false)] { + let before = sqlx::query_scalar!("SELECT xmin::text FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(&pool) + .await + .unwrap(); + let (task, event) = + set_task_active_record(&pool, ids.org_id, ids.user_id, ids.task_id, active) + .await + .unwrap(); + assert_eq!((task.active, task.default_rate_cents), (active, None)); + assert_eq!(event.is_some(), changed); + if let Some(event) = event { + let payload = match event { + crate::plugin::AppEvent::TaskReactivated { org_id, task, .. } => { + assert_eq!(org_id, ids.org_id); + assert!(active); + task + } + crate::plugin::AppEvent::TaskDeactivated { org_id, task, .. } => { + assert_eq!(org_id, ids.org_id); + assert!(!active); + task + } + other => panic!("unexpected activity event: {other:?}"), + }; + assert_eq!(payload.default_rate_cents, Some(8000)); + } + let saved = sqlx::query!("SELECT xmin::text AS version,default_rate_cents,default_rate_currency FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(&pool).await.unwrap(); + assert_eq!(saved.version != before, changed); + assert_eq!( + ( + saved.default_rate_cents, + saved.default_rate_currency.as_deref() + ), + (Some(8000), Some("EUR")) + ); + let history = sqlx::query!( + "SELECT minutes,billable,task_id FROM time_entries WHERE id=$1", + entry + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + (history.minutes, history.billable, history.task_id), + (60, true, ids.task_id) + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn activity_rejects_unavailable_authority_even_for_noops(pool: PgPool) { + for case in [ + "missing", + "malformed", + "unknown_policy", + "inactive", + "no_grant", + ] { + let (ids, _) = fixture( + &pool, + OrgRole::Admin, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + match case { + "missing" => { + sqlx::query!( + "DELETE FROM person_permission_states WHERE user_id=$1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + } + "malformed" => { + sqlx::query!("UPDATE person_permission_states SET grants=ARRAY['unknown.permission'] WHERE user_id=$1", ids.user_id) + .execute(&pool).await.unwrap(); + } + "unknown_policy" => { + sqlx::query!( + "UPDATE organizations SET permission_policy_version=2 WHERE id=$1", + ids.org_id + ) + .execute(&pool) + .await + .unwrap(); + } + "inactive" => { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + } + "no_grant" => { + let stored: Vec = serde_json::from_value( + serde_json::to_value(PermissionSelection::new(&[])).unwrap(), + ) + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET grants=$2 WHERE user_id=$1", + ids.user_id, + &stored + ) + .execute(&pool) + .await + .unwrap(); + } + _ => unreachable!(), + } + for active in [false, true] { + let result = + set_task_active_record(&pool, ids.org_id, ids.user_id, ids.task_id, active).await; + assert!( + matches!( + result, + Err(ServerFnError::ServerError { + code: FORBIDDEN, + .. + }) + ), + "{case}: {result:?}" + ); + } + assert!( + sqlx::query_scalar!("SELECT active FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(&pool) + .await + .unwrap() + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn activity_observes_revocation_after_organization_wait(pool: PgPool) { + let (ids, _) = fixture( + &pool, + OrgRole::Admin, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + let mut hold = pool.begin().await.unwrap(); + lock_organization(&mut hold, ids.org_id, OrganizationLock::AccessChange) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let pending_pool = pool.clone(); + let pending = tokio::spawn(async move { + set_task_active_record(&pending_pool, ids.org_id, ids.user_id, ids.task_id, false).await + }); + wait_for_blocked(&pool, holder).await; + let stored: Vec = + serde_json::from_value(serde_json::to_value(PermissionSelection::new(&[])).unwrap()) + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET grants=$2 WHERE user_id=$1", + ids.user_id, + &stored + ) + .execute(&mut *hold) + .await + .unwrap(); + hold.commit().await.unwrap(); + let result = pending.await.unwrap(); + assert!( + matches!( + result, + Err(ServerFnError::ServerError { + code: FORBIDDEN, + .. + }) + ), + "{result:?}" + ); + assert!( + sqlx::query_scalar!("SELECT active FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(&pool) + .await + .unwrap() + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn archive_waits_for_existing_time_writer_and_observes_committed_timer(pool: PgPool) { + let (ids, _) = fixture( + &pool, + OrgRole::Member, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + let entry = time_entry(&pool, &ids, EntryState::Open).await; + let mut writer = pool.begin().await.unwrap(); + lock_organization(&mut writer, ids.org_id, OrganizationLock::Shared) + .await + .unwrap(); + sqlx::query!( + "UPDATE time_entries SET is_running=true,started_at=now() WHERE id=$1", + entry + ) + .execute(&mut *writer) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *writer) + .await + .unwrap() + .unwrap(); + let pending_pool = pool.clone(); + let pending = tokio::spawn(async move { + set_task_active_record(&pending_pool, ids.org_id, ids.user_id, ids.task_id, false).await + }); + wait_for_blocked(&pool, holder).await; + writer.commit().await.unwrap(); + let error = pending.await.unwrap().unwrap_err(); + assert!( + matches!(error, ServerFnError::ServerError { code: CONFLICT, .. }), + "{error:?}" + ); + assert!(!error.to_string().contains(&entry.to_string())); + assert!(!error.to_string().contains(&ids.user_id.to_string())); + assert!( + sqlx::query_scalar!("SELECT active FROM tasks WHERE id=$1", ids.task_id) + .fetch_one(&pool) + .await + .unwrap() + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn archive_excludes_new_tracking_writers_before_task_lock_and_commit(pool: PgPool) { + let (ids, _) = fixture( + &pool, + OrgRole::Member, + PermissionSelection::new(&[Permission::TaskWriteAll]), + ) + .await; + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("SELECT id FROM tasks WHERE id=$1 FOR UPDATE", ids.task_id) + .fetch_one(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let pending_pool = pool.clone(); + let pending = tokio::spawn(async move { + set_task_active_record(&pending_pool, ids.org_id, ids.user_id, ids.task_id, false).await + }); + wait_for_blocked(&pool, holder).await; + let mut writer = pool.begin().await.unwrap(); + let error = sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR SHARE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap_err(); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("55P03") + ); + writer.rollback().await.unwrap(); + hold.commit().await.unwrap(); + let (task, event) = pending.await.unwrap().unwrap(); + assert!(!task.active); + assert!(event.is_some()); +} diff --git a/crates/horae/src/server_fns/time_entries/commands.rs b/crates/horae/src/server_fns/time_entries/commands.rs index fa3a3d88..679d0698 100644 --- a/crates/horae/src/server_fns/time_entries/commands.rs +++ b/crates/horae/src/server_fns/time_entries/commands.rs @@ -119,6 +119,7 @@ async fn choices( JOIN project_tasks pt ON pt.project_id=p.id JOIN tasks t ON t.id=pt.task_id AND t.org_id=u.org_id WHERE u.org_id=$1 AND u.id=$2 AND u.active AND p.active AND c.active AND t.active + AND ($5 OR pt.active) AND ($3 OR p.id=ANY($4)) AND (($5 AND u.org_role='admin') OR EXISTS ( SELECT 1 FROM assignments a WHERE a.user_id=u.id AND a.project_id=p.id)) diff --git a/crates/horae/src/server_fns/time_entries/commands/tests.rs b/crates/horae/src/server_fns/time_entries/commands/tests.rs index 45001154..9d8ffcbc 100644 --- a/crates/horae/src/server_fns/time_entries/commands/tests.rs +++ b/crates/horae/src/server_fns/time_entries/commands/tests.rs @@ -7,6 +7,55 @@ use horae_core::permissions::catalog::{Permission, PermissionSelection}; mod guards; mod revocation; +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn archived_project_task_excludes_choices_and_new_time_without_changing_global_task( + pool: PgPool, +) { + let fixture = fixture(&pool).await; + sqlx::query!( + "UPDATE project_tasks SET active=false WHERE project_id=$1 AND task_id=$2", + fixture.ids.project_id, + fixture.ids.task_id + ) + .execute(&pool) + .await + .unwrap(); + assert!( + tracking( + &pool, + fixture.ids.org_id, + fixture.ids.user_id, + &fixture.context + ) + .await + .unwrap() + .is_empty() + ); + for command in [ + TimesheetCommand::Create { + entry: fixture.input(), + }, + TimesheetCommand::StartTimer { + project_id: fixture.ids.project_id, + task_id: fixture.ids.task_id, + notes: None, + }, + ] { + let error = fixture.execute(&pool, command).await.unwrap_err(); + assert!( + matches!(error, ServerFnError::ServerError { code: CONFLICT, .. }), + "{error:?}" + ); + } + assert!( + sqlx::query_scalar!("SELECT active FROM tasks WHERE id=$1", fixture.ids.task_id) + .fetch_one(&pool) + .await + .unwrap() + ); +} + struct Fixture { ids: SeedIds, context: TimesheetWriteContext, diff --git a/crates/horae/src/server_fns/users/tests/authority.rs b/crates/horae/src/server_fns/users/tests/authority.rs index 9fa911dd..9a4ffaa9 100644 --- a/crates/horae/src/server_fns/users/tests/authority.rs +++ b/crates/horae/src/server_fns/users/tests/authority.rs @@ -27,6 +27,7 @@ async fn project_editor_and_user_revocation_commit_in_gate_order(pool: PgPool) { .await .unwrap(); let mut request = ProjectEditRequest { + task_activity: Vec::new(), id: Uuid::now_v7(), project_id: ids.project_id, expected_revision: original.revision, diff --git a/crates/horae/tests/browser/task-read-permissions.cjs b/crates/horae/tests/browser/task-read-permissions.cjs index 8575a6b8..bd23b61f 100644 --- a/crates/horae/tests/browser/task-read-permissions.cjs +++ b/crates/horae/tests/browser/task-read-permissions.cjs @@ -58,10 +58,9 @@ assert.equal(sql(`SELECT count(*) FROM time_entries WHERE user_id='${actor.id}' VALUES ('${id(9)}','${org}','${actor.id}',1,${array(floor)},false,'individual'); UPDATE organizations SET permission_policy_version=1 WHERE id='${org}'; COMMIT;`); await page.goto(`${base}/auth/login`); + const response = identities(); await page.getByRole('button', { name: 'Sign in as Admin', exact: true }).click(); await page.waitForURL(`${base}/`); - const response = identities(); - await page.reload(); const rows = await (await response).json(); assert.ok(rows.some(row => row.id === historical)); assert.ok(rows.some(row => row.id === enabled)); diff --git a/crates/horae/tests/new_project_screen.rs b/crates/horae/tests/new_project_screen.rs index 7db8926d..1af2e20b 100644 --- a/crates/horae/tests/new_project_screen.rs +++ b/crates/horae/tests/new_project_screen.rs @@ -332,6 +332,7 @@ fn canonical_editor() -> Probe { people: vec![], }, inactive_task_ids: vec![], + archived_task_ids: vec![], inactive_user_ids: vec![], access: Some(ProjectEditorAccess { requester, @@ -1125,6 +1126,7 @@ async fn edit_prefills_legacy_currency_and_type_without_reading_or_saving_a_draf people: vec![], }, inactive_task_ids: vec![], + archived_task_ids: vec![], inactive_user_ids: vec![], }); let writes = probe.writes.clone();