diff --git a/migrations/20230913024611_organizations.sql b/migrations/20230913024611_organizations.sql new file mode 100644 index 00000000..b6e7ae9c --- /dev/null +++ b/migrations/20230913024611_organizations.sql @@ -0,0 +1,17 @@ +CREATE TABLE organizations ( + id bigint PRIMARY KEY, + title varchar(255) NOT NULL, -- also slug + description text NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + team_id bigint NOT NULL REFERENCES teams(id) ON UPDATE CASCADE, + + icon_url varchar(255) NULL, + color integer NULL +); + +ALTER TABLE mods ADD COLUMN organization_id bigint NULL REFERENCES organizations(id) ON DELETE SET NULL; + +-- Organization permissions only apply to teams that are associated to an organization +-- If they do, 'permissions' is considered the fallback permissions for projects in the organization +ALTER TABLE team_members ADD COLUMN organization_permissions bigint NULL; diff --git a/sqlx-data.json b/sqlx-data.json index d8c816cc..46c3d051 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -12,6 +12,75 @@ }, "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n " }, + "0244926b35b964da2b50ccf82aff001250a3751d2314707c4884066432aa4753": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "role", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 7, + "type_info": "Numeric" + }, + { + "name": "ordering", + "ordinal": 8, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE\n WHERE m.id = $1\n " + }, "02843e787de72594e186a14734bd02099ca6d2f07dcc06da8d6d8a069638ca2a": { "describe": { "columns": [ @@ -124,6 +193,57 @@ }, "query": "\n UPDATE mods\n SET icon_url = NULL, color = NULL\n WHERE (id = $1)\n " }, + "05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "team_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "icon_url", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 5, + "type_info": "Int4" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + } + }, + "query": "\n SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color\n FROM organizations o\n WHERE o.id = ANY($1) OR o.title = ANY($2)\n GROUP BY o.id;\n " + }, "05baeb26d9856218e5c6f8856a96788b2a7ac3536ff9412a50552cef1d561a1e": { "describe": { "columns": [], @@ -137,6 +257,32 @@ }, "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n " }, + "061a3e43df9464263aaf1555a27c1f4b6a0f381282f4fa75cc13b1d354857578": { + "describe": { + "columns": [ + { + "name": "pid", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "oid", + "ordinal": 1, + "type_info": "Int8" + } + ], + "nullable": [ + null, + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT m.id AS pid, NULL AS oid\n FROM mods m\n WHERE m.team_id = $1\n \n UNION ALL\n \n SELECT NULL AS pid, o.id AS oid\n FROM organizations o\n WHERE o.team_id = $1 \n " + }, "06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223": { "describe": { "columns": [], @@ -150,6 +296,19 @@ }, "query": "\n UPDATE versions\n SET downloads = $1\n WHERE (id = $2)\n " }, + "07b692d2f89cdcc66da4e1a834f6fefe6a24c13c287490662585749b2b8baae3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + } + }, + "query": "\n UPDATE organizations\n SET title = LOWER($1)\n WHERE (id = $2)\n " + }, "07ebc9dc82cd012cd4f5880b1eb3d82602c195a3e3ddd557103ee037aa6dad1c": { "describe": { "columns": [], @@ -433,67 +592,32 @@ }, "query": "\n INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n " }, - "0d91a3a73844f46ef00d8d45a0d028f1c4c1da016044f63f21d96707eafec858": { + "0c6b7e51b0b9115d95b5dbb9bb88a3e266b78ae9375a90261503c2cccd5bdf1b": { "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "member_role", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 4, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 5, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 6, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 7, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ], + "columns": [], + "nullable": [], "parameters": { "Left": [ - "Int8Array" + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE mods\n SET organization_id = $1\n WHERE (id = $2)\n " + }, + "0eb293a353be47c61620922634cc339eda0e2422fcc602d7506c7cdf6152c928": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Int8" ] } }, - "query": "\n SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering,\n tm.user_id user_id\n FROM team_members tm\n WHERE tm.team_id = ANY($1)\n ORDER BY tm.team_id, tm.ordering\n " + "query": "\n UPDATE organizations\n SET icon_url = $1, color = $2\n WHERE (id = $3)\n " }, "0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40": { "describe": { @@ -740,6 +864,18 @@ }, "query": "\n INSERT INTO payouts_values (user_id, mod_id, amount, created)\n VALUES ($1, $2, $3, $4)\n " }, + "19c7498a01f51b8220245a53490916191a153fa1fe14404d39ab2980e3386058": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE mods\n SET organization_id = NULL\n WHERE (id = $1)\n " + }, "19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da": { "describe": { "columns": [], @@ -1438,6 +1574,20 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)" }, + "3533fb2c185019bd2f4e5a89499ac19fec99452146cc80405b32d961ec50e456": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE team_members\n SET organization_permissions = $1\n WHERE (team_id = $2 AND user_id = $3)\n " + }, "371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6": { "describe": { "columns": [], @@ -2190,37 +2340,17 @@ }, "query": "\n SELECT id FROM threads\n WHERE report_id = $1\n " }, - "599a7966e054d7892c6c48c6f303872bb51f2b5eb387a3967bf8aebb5d33f627": { + "599df07263a2705e57fc70a7c4f5dc606e1730c281e3b573d2f2a2030bed04e0": { "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], + "columns": [], + "nullable": [], "parameters": { "Left": [ - "Int8" + "Int8Array" ] } }, - "query": "\n SELECT m.id\n FROM mods m\n WHERE m.team_id = $1\n " - }, - "599df07263a2705e57fc70a7c4f5dc606e1730c281e3b573d2f2a2030bed04e0": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "query": "\n DELETE FROM notifications\n WHERE id = ANY($1)\n " + "query": "\n DELETE FROM notifications\n WHERE id = ANY($1)\n " }, "59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b": { "describe": { @@ -2769,6 +2899,75 @@ }, "query": "\n UPDATE sessions\n SET last_login = $2, city = $3, country = $4, ip = $5, os = $6, platform = $7, user_agent = $8\n WHERE (id = $1)\n " }, + "65b5acdce6675d9c2abe636793dafef8ec915ddcc11a2735c66a49a48f314dd6": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "role", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 7, + "type_info": "Numeric" + }, + { + "name": "ordering", + "ordinal": 8, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = TRUE\n WHERE o.id = $1\n " + }, "665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a": { "describe": { "columns": [], @@ -3078,6 +3277,34 @@ }, "query": "\n SELECT COUNT(id)\n FROM mods\n WHERE status = ANY($1)\n " }, + "6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "Int8" + ] + } + }, + "query": "\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n LEFT JOIN organizations o ON o.team_id = tm.team_id\n WHERE (tm.team_id = ANY($1) or o.id = ANY($2)) AND tm.user_id = $3\n " + }, "6f594641f9633fbab31a57ebdbd33dd74f89e45252dfc2ae1cdbda549291b21b": { "describe": { "columns": [], @@ -3110,23 +3337,6 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)" }, - "6fd06767f42be894c7a35c6b61f43407c55de43dc77ed02b39062278f3de81e3": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Varchar", - "Int8", - "Bool" - ] - } - }, - "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5, $6\n )\n " - }, "70b510956a40583eef8c57dcced71c67f525eee455ae8b09e9b2403668068751": { "describe": { "columns": [], @@ -3330,6 +3540,23 @@ }, "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " }, + "77be410d0687b65b3554a35740fcf3c02418c5897856000716a35c02eed43d5a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Text", + "Varchar", + "Int4" + ] + } + }, + "query": "\n INSERT INTO organizations (id, title, team_id, description, icon_url, color)\n VALUES ($1, $2, $3, $4, $5, $6)\n " + }, "78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee": { "describe": { "columns": [], @@ -3442,288 +3669,32 @@ }, "query": "\n SELECT n.id FROM notifications n\n WHERE n.user_id = $1\n " }, - "7b1d14e79d07247bf3061accdccdd83a36abb186ebeb253f34daf6c7337c6f7c": { + "7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1": { "describe": { "columns": [ { - "name": "id", + "name": "amount", "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "project_type", - "ordinal": 1, - "type_info": "Int4" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "downloads", - "ordinal": 4, - "type_info": "Int4" - }, - { - "name": "follows", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 6, - "type_info": "Varchar" - }, - { - "name": "body", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "published", - "ordinal": 8, - "type_info": "Timestamptz" - }, - { - "name": "updated", - "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "approved", - "ordinal": 10, - "type_info": "Timestamptz" - }, - { - "name": "queued", - "ordinal": 11, - "type_info": "Timestamptz" - }, - { - "name": "status", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "requested_status", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "issues_url", - "ordinal": 14, - "type_info": "Varchar" - }, - { - "name": "source_url", - "ordinal": 15, - "type_info": "Varchar" - }, - { - "name": "wiki_url", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "discord_url", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "license_url", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "team_id", - "ordinal": 19, - "type_info": "Int8" - }, + "type_info": "Numeric" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT SUM(pv.amount) amount\n FROM payouts_values pv\n WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval\n " + }, + "7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea": { + "describe": { + "columns": [ { - "name": "client_side", - "ordinal": 20, - "type_info": "Int4" - }, - { - "name": "server_side", - "ordinal": 21, - "type_info": "Int4" - }, - { - "name": "license", - "ordinal": 22, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 23, - "type_info": "Varchar" - }, - { - "name": "moderation_message", - "ordinal": 24, - "type_info": "Varchar" - }, - { - "name": "moderation_message_body", - "ordinal": 25, - "type_info": "Varchar" - }, - { - "name": "client_side_type", - "ordinal": 26, - "type_info": "Varchar" - }, - { - "name": "server_side_type", - "ordinal": 27, - "type_info": "Varchar" - }, - { - "name": "project_type_name", - "ordinal": 28, - "type_info": "Varchar" - }, - { - "name": "webhook_sent", - "ordinal": 29, - "type_info": "Bool" - }, - { - "name": "color", - "ordinal": 30, - "type_info": "Int4" - }, - { - "name": "thread_id", - "ordinal": 31, - "type_info": "Int8" - }, - { - "name": "monetization_status", - "ordinal": 32, - "type_info": "Varchar" - }, - { - "name": "loaders", - "ordinal": 33, - "type_info": "VarcharArray" - }, - { - "name": "game_versions", - "ordinal": 34, - "type_info": "VarcharArray" - }, - { - "name": "categories", - "ordinal": 35, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 36, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 37, - "type_info": "Jsonb" - }, - { - "name": "gallery", - "ordinal": 38, - "type_info": "Jsonb" - }, - { - "name": "donations", - "ordinal": 39, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - false, - true, - true, - false, - true, - true, - true, - true, - true, - true, - false, - false, - false, - false, - true, - true, - true, - false, - false, - false, - false, - true, - false, - false, - false, - false, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TextArray" - ] - } - }, - "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,\n m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, cs.id, ss.id, t.id, m.id;\n " - }, - "7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1": { - "describe": { - "columns": [ - { - "name": "amount", - "ordinal": 0, - "type_info": "Numeric" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT SUM(pv.amount) amount\n FROM payouts_values pv\n WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval\n " - }, - "7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, + "name": "id", + "ordinal": 0, "type_info": "Int4" } ], @@ -3863,69 +3834,6 @@ }, "query": "\n UPDATE collections\n SET status = $1\n WHERE (id = $2)\n " }, - "868c29019bd7e9ad71fb3515ca3489304ade3f6ebe3f77c018a8a521a96fb41f": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 4, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 5, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 6, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 7, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n " - }, "868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d": { "describe": { "columns": [], @@ -3991,29 +3899,104 @@ }, "query": "\n DELETE FROM team_members\n WHERE (team_id = $1 AND user_id = $2 AND NOT role = $3)\n " }, - "8cbd74dad7a21128d99fd32b430c2e0427480f910e1f125ff56b893c67a6e8a4": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE users\n SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3\n WHERE (id = $4)\n " - }, - "8f5e2a570cf35b2d158182bac37fd40bcec277bbdeddaece5efaa88600048a70": { + "8c93ad7aa81a0502494ff98dd6120c34d583d1a205b4c97ac54a7230b8c23765": { "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "role", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 7, + "type_info": "Numeric" + }, + { + "name": "ordering", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "mod_id", + "ordinal": 9, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id \n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n " + }, + "8cbd74dad7a21128d99fd32b430c2e0427480f910e1f125ff56b893c67a6e8a4": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3\n WHERE (id = $4)\n " + }, + "8f5e2a570cf35b2d158182bac37fd40bcec277bbdeddaece5efaa88600048a70": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] } }, "query": "\n UPDATE threads\n SET show_in_mod_inbox = FALSE\n WHERE id = $1\n " @@ -4030,6 +4013,18 @@ }, "query": "\n DELETE FROM threads_members\n WHERE user_id = $1\n " }, + "91736b6bcc7a08c835cd3f3cea3a133ca42694df8fc3ce34b35d39bea6e1bba1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE organizations\n SET icon_url = NULL, color = NULL\n WHERE (id = $1)\n " + }, "92c00ebff25cfb0464947ea48faac417fabdb3cb3edd5ed45720598c7c12c689": { "describe": { "columns": [], @@ -4112,6 +4107,26 @@ }, "query": "\n UPDATE collections\n SET icon_url = $1, color = $2\n WHERE (id = $3)\n " }, + "957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT m.id\n FROM mods m\n WHERE m.organization_id = $1\n " + }, "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": { "describe": { "columns": [], @@ -4124,17 +4139,47 @@ }, "query": "\n DELETE FROM pats WHERE id = $1\n " }, - "97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43": { + "9608a95084c55d939d3f908f3dd7e53cb1c9455b5d53868993147bf6abc42ffb": { "describe": { "columns": [ { - "name": "exists", + "name": "id", "ordinal": 0, - "type_info": "Bool" + "type_info": "Int8" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "team_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "icon_url", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 5, + "type_info": "Int4" } ], "nullable": [ - null + false, + false, + false, + false, + true, + true ], "parameters": { "Left": [ @@ -4142,34 +4187,27 @@ ] } }, - "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)" + "query": "\n SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color\n FROM organizations o\n LEFT JOIN mods m ON m.organization_id = o.id\n WHERE m.id = $1\n GROUP BY o.id;\n " }, - "980e2ebd1b77baecff5b302b063d8f359ddbdb68452c4c8f2a53dc8d6a2127a4": { + "97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43": { "describe": { "columns": [ { - "name": "id", + "name": "exists", "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" + "type_info": "Bool" } ], "nullable": [ - false, - false + null ], "parameters": { "Left": [ - "Int8Array", "Int8" ] } }, - "query": "\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n WHERE tm.team_id = ANY($1) AND tm.user_id = $2\n " + "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)" }, "99a1eac69d7f5a5139703df431e6a5c3012a90143a8c635f93632f04d0bc41d4": { "describe": { @@ -4235,25 +4273,6 @@ }, "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n " }, - "9cf0b1e3a91ce821865dbfbfb292193311be63bc0e79ab762efe84c19de510c6": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Varchar", - "Int8", - "Bool", - "Numeric", - "Int8" - ] - } - }, - "query": "\n INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n " - }, "9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400": { "describe": { "columns": [], @@ -4337,69 +4356,6 @@ }, "query": "\n SELECT COUNT(f.id) FROM files f\n INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2)\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n " }, - "a31bce5cec7583d71c140ff84a2c93a6127efee7b5607ca6e609570396f44f27": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 4, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 5, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 6, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 7, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE\n WHERE m.id = $1\n " - }, "a440cb2567825c3cc540c9b0831ee840f6e2a6394e89a851b83fc78220594cf2": { "describe": { "columns": [], @@ -4673,10 +4629,31 @@ }, "query": "\n INSERT INTO threads_messages (\n id, author_id, body, thread_id\n )\n VALUES (\n $1, $2, $3, $4\n )\n " }, - "b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a": { + "b139baf2b1424d1f38b9d80f3a33baf12195bcbac34bb779483e42315803b875": { "describe": { - "columns": [], - "nullable": [], + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT EXISTS(\n SELECT 1 \n FROM organizations o JOIN team_members tm ON tm.team_id = o.team_id\n WHERE o.id = $1 AND tm.user_id = $2\n )" + }, + "b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a": { + "describe": { + "columns": [], + "nullable": [], "parameters": { "Left": [ "Numeric", @@ -4686,6 +4663,26 @@ }, "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n " }, + "b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)\n " + }, "b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398": { "describe": { "columns": [], @@ -5199,6 +5196,26 @@ }, "query": "\n UPDATE mods\n SET client_side = $1\n WHERE (id = $2)\n " }, + "c6060a389343c9f35aea5d931518b85ab7c71b6ba74eae7b4b51d881f1798c6e": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8", + "Int8", + "Bool", + "Numeric", + "Int8" + ] + } + }, + "query": "\n INSERT INTO team_members (id, team_id, user_id, role, permissions, organization_permissions, accepted, payouts_split, ordering)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n " + }, "c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d": { "describe": { "columns": [], @@ -5352,6 +5369,24 @@ }, "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n " }, + "cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8", + "Int8", + "Bool" + ] + } + }, + "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, organization_permissions, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n " + }, "ccce60dc60ca6c4ea1142ab6d0d81bdb1ee9ed97c992695324aec015e0e190bf": { "describe": { "columns": [], @@ -5410,6 +5445,81 @@ }, "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n " }, + "ce20a9c53249e255be7312819f505d935d3ab2ee3c21a6422e5b12155c159bd7": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "member_role", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 6, + "type_info": "Numeric" + }, + { + "name": "role", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "ordering", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 9, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT id, team_id, role AS member_role, permissions, organization_permissions,\n accepted, payouts_split, role,\n ordering, user_id\n \n FROM team_members\n WHERE (team_id = $1 AND user_id = $2)\n ORDER BY ordering\n " + }, "ce2e7642142f79bdce78ba3316fe402e18ae203cc65fe79f724d37a7076df2dd": { "describe": { "columns": [], @@ -5585,6 +5695,81 @@ }, "query": "\n UPDATE users\n SET discord_id = $2\n WHERE (id = $1)\n " }, + "d55bdef50adf0b8a547022d0a041bec8618da02d82a1138da77d8885c0d9cfb9": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "member_role", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 6, + "type_info": "Numeric" + }, + { + "name": "role", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "ordering", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 9, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + } + }, + "query": "\n SELECT id, team_id, role AS member_role, permissions, organization_permissions,\n accepted, payouts_split, role,\n ordering, user_id\n FROM team_members\n WHERE (team_id = ANY($1) AND user_id = $2 AND accepted = TRUE)\n ORDER BY ordering\n " + }, "d59a0ca4725d40232eae8bf5735787e1b76282c390d2a8d07fb34e237a0b2132": { "describe": { "columns": [], @@ -5618,6 +5803,26 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)" }, + "d698ca87442da9d26bd1f4636af9a58509c2687f7621765663bdf18988c9c79e": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)" + }, "d75b73151ba84715c06bbada22b66c819de8eac87c088b0a501212ad3fe4d618": { "describe": { "columns": [], @@ -5732,64 +5937,7 @@ }, "query": "\n UPDATE mods\n SET body = $1\n WHERE (id = $2)\n " }, - "dc83c501515b12bf1cb02a195d8bbd49a0a488626b280606b51b90bd7cecf46b": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "role", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 4, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 5, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 6, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n SELECT id, user_id, role, permissions, accepted, payouts_split, ordering\n FROM team_members\n WHERE (team_id = $1 AND user_id = $2)\n " - }, - "dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0": { + "dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0": { "describe": { "columns": [], "nullable": [], @@ -5912,6 +6060,26 @@ }, "query": "\n SELECT name FROM report_types\n " }, + "e3389d233c75649e95456d504d1b716d520a03a8a3e0cc5311a4a753f1f04614": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1))\n " + }, "e37ecb6dc1509d390bb6f68ba25899d19f693554d8969bbf8f8ee14a78adf0f9": { "describe": { "columns": [], @@ -5963,6 +6131,27 @@ }, "query": "\n UPDATE versions\n SET featured = $1\n WHERE (id = $2)\n " }, + "e60561aeefbc2bed1f77ff4bbca763b5be84bd6bc3eff75ca57e3590be286d45": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "\n SELECT m.id FROM organizations o\n LEFT JOIN mods m ON m.id = o.id\n WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL)\n " + }, "e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3": { "describe": { "columns": [ @@ -5983,6 +6172,74 @@ }, "query": "\n SELECT c.id FROM collections c\n WHERE c.user_id = $1\n " }, + "e6db02891be261e61a25716b83c1298482eb9a04f0c026532030aeb374405f13": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "member_role", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "organization_permissions", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 6, + "type_info": "Numeric" + }, + { + "name": "ordering", + "ordinal": 7, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n SELECT id, team_id, role AS member_role, permissions, organization_permissions,\n accepted, payouts_split, \n ordering, user_id\n FROM team_members\n WHERE team_id = ANY($1)\n ORDER BY team_id, ordering;\n " + }, "e6f5a150cbd3bd6b9bde9e5cdad224a45c96d678b69ec12508e81246710e3f6d": { "describe": { "columns": [ @@ -6046,6 +6303,19 @@ }, "query": "\n SELECT id, name, access_token, scopes, user_id, created, expires, last_used\n FROM pats\n WHERE id = ANY($1) OR access_token = ANY($2)\n ORDER BY created DESC\n " }, + "e74fad4e44759b82df6cde8a4e6df7dc0eb31968a7acfb5069d9e5202c1ad803": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + } + }, + "query": "\n UPDATE organizations\n SET description = $1\n WHERE (id = $2)\n " + }, "e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab": { "describe": { "columns": [ @@ -6387,6 +6657,18 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)" }, + "f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM organizations\n WHERE id = $1\n " + }, "f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742": { "describe": { "columns": [ @@ -6441,69 +6723,6 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)" }, - "fa45b0e9d3e281d15e6283fe0e154254112df02e26778d7a01d09da2cd26e4bd": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "role", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 4, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 5, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 6, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 7, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8Array", - "Int8" - ] - } - }, - "query": "\n SELECT id, team_id, user_id, role, permissions, accepted, payouts_split, ordering\n FROM team_members\n WHERE (team_id = ANY($1) AND user_id = $2 AND accepted = TRUE)\n ORDER BY ordering\n " - }, "fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7": { "describe": { "columns": [ @@ -6630,5 +6849,267 @@ } }, "query": "\n SELECT follower_id FROM mod_follows\n WHERE mod_id = $1\n " + }, + "ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "project_type", + "ordinal": 1, + "type_info": "Int4" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "downloads", + "ordinal": 4, + "type_info": "Int4" + }, + { + "name": "follows", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "body", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "published", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "updated", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "approved", + "ordinal": 10, + "type_info": "Timestamptz" + }, + { + "name": "queued", + "ordinal": 11, + "type_info": "Timestamptz" + }, + { + "name": "status", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "requested_status", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "issues_url", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "source_url", + "ordinal": 15, + "type_info": "Varchar" + }, + { + "name": "wiki_url", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "discord_url", + "ordinal": 17, + "type_info": "Varchar" + }, + { + "name": "license_url", + "ordinal": 18, + "type_info": "Varchar" + }, + { + "name": "team_id", + "ordinal": 19, + "type_info": "Int8" + }, + { + "name": "organization_id", + "ordinal": 20, + "type_info": "Int8" + }, + { + "name": "client_side", + "ordinal": 21, + "type_info": "Int4" + }, + { + "name": "server_side", + "ordinal": 22, + "type_info": "Int4" + }, + { + "name": "license", + "ordinal": 23, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 24, + "type_info": "Varchar" + }, + { + "name": "moderation_message", + "ordinal": 25, + "type_info": "Varchar" + }, + { + "name": "moderation_message_body", + "ordinal": 26, + "type_info": "Varchar" + }, + { + "name": "client_side_type", + "ordinal": 27, + "type_info": "Varchar" + }, + { + "name": "server_side_type", + "ordinal": 28, + "type_info": "Varchar" + }, + { + "name": "project_type_name", + "ordinal": 29, + "type_info": "Varchar" + }, + { + "name": "webhook_sent", + "ordinal": 30, + "type_info": "Bool" + }, + { + "name": "color", + "ordinal": 31, + "type_info": "Int4" + }, + { + "name": "thread_id", + "ordinal": 32, + "type_info": "Int8" + }, + { + "name": "monetization_status", + "ordinal": 33, + "type_info": "Varchar" + }, + { + "name": "loaders", + "ordinal": 34, + "type_info": "VarcharArray" + }, + { + "name": "game_versions", + "ordinal": 35, + "type_info": "VarcharArray" + }, + { + "name": "categories", + "ordinal": 36, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 37, + "type_info": "VarcharArray" + }, + { + "name": "versions", + "ordinal": 38, + "type_info": "Jsonb" + }, + { + "name": "gallery", + "ordinal": 39, + "type_info": "Jsonb" + }, + { + "name": "donations", + "ordinal": 40, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray" + ] + } + }, + "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, cs.id, ss.id, t.id, m.id;\n " } } \ No newline at end of file diff --git a/src/auth/checks.rs b/src/auth/checks.rs index 6cf65223..b358494c 100644 --- a/src/auth/checks.rs +++ b/src/auth/checks.rs @@ -31,7 +31,26 @@ pub async fn is_authorized( .await? .exists; - authorized = project_exists.unwrap_or(false); + let organization_exists = + if let Some(organization_id) = project_data.organization_id { + sqlx::query!( + "SELECT EXISTS( + SELECT 1 + FROM organizations o JOIN team_members tm ON tm.team_id = o.team_id + WHERE o.id = $1 AND tm.user_id = $2 + )", + organization_id as database::models::ids::OrganizationId, + user_id as database::models::ids::UserId, + ) + .fetch_one(&***pool) + .await? + .exists + } else { + None + }; + + authorized = + project_exists.unwrap_or(false) || organization_exists.unwrap_or(false); } } } @@ -70,12 +89,17 @@ pub async fn filter_authorized_projects( " SELECT m.id id, m.team_id team_id FROM team_members tm INNER JOIN mods m ON m.team_id = tm.team_id - WHERE tm.team_id = ANY($1) AND tm.user_id = $2 + LEFT JOIN organizations o ON o.team_id = tm.team_id + WHERE (tm.team_id = ANY($1) or o.id = ANY($2)) AND tm.user_id = $3 ", &check_projects .iter() .map(|x| x.inner.team_id.0) .collect::>(), + &check_projects + .iter() + .filter_map(|x| x.inner.organization_id.map(|x| x.0)) + .collect::>(), user_id as database::models::ids::UserId, ) .fetch_many(&***pool) diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 98d0f54b..11169520 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -62,6 +62,13 @@ generate_ids!( "SELECT EXISTS(SELECT 1 FROM teams WHERE id=$1)", TeamId ); +generate_ids!( + pub generate_organization_id, + OrganizationId, + 8, + "SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)", + OrganizationId +); generate_ids!( pub generate_collection_id, CollectionId, @@ -145,17 +152,21 @@ generate_ids!( ImageId ); -#[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); -#[derive(Copy, Clone, Debug, Type, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Type, Eq, Hash, PartialEq, Serialize, Deserialize)] #[sqlx(transparent)] pub struct TeamId(pub i64); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] #[sqlx(transparent)] pub struct TeamMemberId(pub i64); +#[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct OrganizationId(pub i64); + #[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct ProjectId(pub i64); @@ -259,6 +270,16 @@ impl From for ids::TeamId { ids::TeamId(id.0 as u64) } } +impl From for OrganizationId { + fn from(id: ids::OrganizationId) -> Self { + OrganizationId(id.0 as i64) + } +} +impl From for ids::OrganizationId { + fn from(id: OrganizationId) -> Self { + ids::OrganizationId(id.0 as u64) + } +} impl From for VersionId { fn from(id: ids::VersionId) -> Self { VersionId(id.0 as i64) diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 4b2909b7..bfd6e781 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -6,6 +6,7 @@ pub mod flow_item; pub mod ids; pub mod image_item; pub mod notification_item; +pub mod organization_item; pub mod pat_item; pub mod project_item; pub mod report_item; @@ -18,6 +19,7 @@ pub mod version_item; pub use collection_item::Collection; pub use ids::*; pub use image_item::Image; +pub use organization_item::Organization; pub use project_item::Project; pub use team_item::Team; pub use team_item::TeamMember; diff --git a/src/database/models/organization_item.rs b/src/database/models/organization_item.rs new file mode 100644 index 00000000..5a52558a --- /dev/null +++ b/src/database/models/organization_item.rs @@ -0,0 +1,352 @@ +use crate::models::ids::base62_impl::{parse_base62, to_base62}; + +use super::{ids::*, TeamMember}; +use redis::cmd; +use serde::{Deserialize, Serialize}; + +const ORGANIZATIONS_NAMESPACE: &str = "organizations"; +const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles"; + +const DEFAULT_EXPIRY: i64 = 1800; + +#[derive(Deserialize, Serialize, Clone, Debug)] +/// An organization of users who together control one or more projects and organizations. +pub struct Organization { + /// The id of the organization + pub id: OrganizationId, + + /// The title (and slug) of the organization + pub title: String, + + /// The associated team of the organization + pub team_id: TeamId, + + /// The description of the organization + pub description: String, + + /// The display icon for the organization + pub icon_url: Option, + pub color: Option, +} + +impl Organization { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), super::DatabaseError> { + sqlx::query!( + " + INSERT INTO organizations (id, title, team_id, description, icon_url, color) + VALUES ($1, $2, $3, $4, $5, $6) + ", + self.id.0, + self.title, + self.team_id as TeamId, + self.description, + self.icon_url, + self.color.map(|x| x as i32), + ) + .execute(&mut *transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, E>( + string: &str, + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[string], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_id<'a, 'b, E>( + id: OrganizationId, + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many_ids(&[id], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, 'b, E>( + organization_ids: &[OrganizationId], + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let ids = organization_ids + .iter() + .map(|x| crate::models::ids::OrganizationId::from(*x)) + .collect::>(); + Self::get_many(&ids, exec, redis).await + } + + pub async fn get_many<'a, E, T: ToString>( + organization_strings: &[T], + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + if organization_strings.is_empty() { + return Ok(Vec::new()); + } + + let mut redis = redis.get().await?; + + let mut found_organizations = Vec::new(); + let mut remaining_strings = organization_strings + .iter() + .map(|x| x.to_string()) + .collect::>(); + + let mut organization_ids = organization_strings + .iter() + .flat_map(|x| parse_base62(&x.to_string()).map(|x| x as i64)) + .collect::>(); + + organization_ids.append( + &mut cmd("MGET") + .arg( + organization_strings + .iter() + .map(|x| { + format!( + "{}:{}", + ORGANIZATIONS_TITLES_NAMESPACE, + x.to_string().to_lowercase() + ) + }) + .collect::>(), + ) + .query_async::<_, Vec>>(&mut redis) + .await? + .into_iter() + .flatten() + .collect(), + ); + + if !organization_ids.is_empty() { + let organizations = cmd("MGET") + .arg( + organization_ids + .iter() + .map(|x| format!("{}:{}", ORGANIZATIONS_NAMESPACE, x)) + .collect::>(), + ) + .query_async::<_, Vec>>(&mut redis) + .await?; + + for organization in organizations { + if let Some(organization) = + organization.and_then(|x| serde_json::from_str::(&x).ok()) + { + remaining_strings.retain(|x| { + &to_base62(organization.id.0 as u64) != x + && organization.title.to_lowercase() != x.to_lowercase() + }); + found_organizations.push(organization); + continue; + } + } + } + + if !remaining_strings.is_empty() { + let organization_ids_parsed: Vec = remaining_strings + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + + let organizations: Vec = sqlx::query!( + " + SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color + FROM organizations o + WHERE o.id = ANY($1) OR o.title = ANY($2) + GROUP BY o.id; + ", + &organization_ids_parsed, + &remaining_strings + .into_iter() + .map(|x| x.to_string().to_lowercase()) + .collect::>(), + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|m| Organization { + id: OrganizationId(m.id), + title: m.title, + team_id: TeamId(m.team_id), + description: m.description, + icon_url: m.icon_url, + color: m.color.map(|x| x as u32), + })) + }) + .try_collect::>() + .await?; + + for organization in organizations { + cmd("SET") + .arg(format!("{}:{}", ORGANIZATIONS_NAMESPACE, organization.id.0)) + .arg(serde_json::to_string(&organization)?) + .arg("EX") + .arg(DEFAULT_EXPIRY) + .query_async::<_, ()>(&mut redis) + .await?; + + cmd("SET") + .arg(format!( + "{}:{}", + ORGANIZATIONS_TITLES_NAMESPACE, + organization.title.to_lowercase() + )) + .arg(organization.id.0) + .arg("EX") + .arg(DEFAULT_EXPIRY) + .query_async::<_, ()>(&mut redis) + .await?; + found_organizations.push(organization); + } + } + + Ok(found_organizations) + } + + // Gets organization associated with a project ID, if it exists and there is one + pub async fn get_associated_organization_project_id<'a, 'b, E>( + project_id: ProjectId, + exec: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color + FROM organizations o + LEFT JOIN mods m ON m.organization_id = o.id + WHERE m.id = $1 + GROUP BY o.id; + ", + project_id as ProjectId, + ) + .fetch_optional(exec) + .await?; + + if let Some(result) = result { + Ok(Some(Organization { + id: OrganizationId(result.id), + title: result.title, + team_id: TeamId(result.team_id), + description: result.description, + icon_url: result.icon_url, + color: result.color.map(|x| x as u32), + })) + } else { + Ok(None) + } + } + + pub async fn remove( + id: OrganizationId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &deadpool_redis::Pool, + ) -> Result, super::DatabaseError> { + use futures::TryStreamExt; + + let organization = Self::get_id(id, &mut *transaction, redis).await?; + + if let Some(organization) = organization { + let projects: Vec = sqlx::query!( + " + SELECT m.id + FROM mods m + WHERE m.organization_id = $1 + ", + id as OrganizationId, + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) }) + .try_collect::>() + .await?; + + for project_id in projects { + let _result = + super::project_item::Project::remove(project_id, transaction, redis).await?; + } + + Organization::clear_cache(id, Some(organization.title), redis).await?; + + sqlx::query!( + " + DELETE FROM organizations + WHERE id = $1 + ", + id as OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + TeamMember::clear_cache(organization.team_id, redis).await?; + + sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 + ", + organization.team_id as TeamId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM teams + WHERE id = $1 + ", + organization.team_id as TeamId, + ) + .execute(&mut *transaction) + .await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn clear_cache( + id: OrganizationId, + title: Option, + redis: &deadpool_redis::Pool, + ) -> Result<(), super::DatabaseError> { + let mut redis = redis.get().await?; + let mut cmd = cmd("DEL"); + cmd.arg(format!("{}:{}", ORGANIZATIONS_NAMESPACE, id.0)); + if let Some(title) = title { + cmd.arg(format!( + "{}:{}", + ORGANIZATIONS_TITLES_NAMESPACE, + title.to_lowercase() + )); + } + cmd.query_async::<_, ()>(&mut redis).await?; + + Ok(()) + } +} diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 81db0e81..a7d85679 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -21,7 +21,7 @@ pub struct DonationUrl { } impl DonationUrl { - pub async fn insert( + pub async fn insert_project( &self, project_id: ProjectId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -90,6 +90,7 @@ pub struct ProjectBuilder { pub project_id: ProjectId, pub project_type_id: ProjectTypeId, pub team_id: TeamId, + pub organization_id: Option, pub title: String, pub description: String, pub body: String, @@ -123,6 +124,7 @@ impl ProjectBuilder { id: self.project_id, project_type: self.project_type_id, team_id: self.team_id, + organization_id: self.organization_id, title: self.title, description: self.description, body: self.body, @@ -165,7 +167,9 @@ impl ProjectBuilder { } for donation in self.donation_urls { - donation.insert(self.project_id, &mut *transaction).await?; + donation + .insert_project(self.project_id, &mut *transaction) + .await?; } for gallery in self.gallery_items { @@ -209,6 +213,7 @@ pub struct Project { pub id: ProjectId, pub project_type: ProjectTypeId, pub team_id: TeamId, + pub organization_id: Option, pub title: String, pub description: String, pub body: String, @@ -553,7 +558,7 @@ impl Project { m.icon_url icon_url, m.body body, m.published published, m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, - m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, + m.team_id team_id, m.organization_id organization_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color, t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, @@ -589,6 +594,7 @@ impl Project { id: ProjectId(id), project_type: ProjectTypeId(m.project_type), team_id: TeamId(m.team_id), + organization_id: m.organization_id.map(OrganizationId), title: m.title.clone(), description: m.description.clone(), downloads: m.downloads, diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs index c3ad0021..f092f8dc 100644 --- a/src/database/models/team_item.rs +++ b/src/database/models/team_item.rs @@ -1,5 +1,5 @@ -use super::ids::*; -use crate::models::teams::Permissions; +use super::{ids::*, Organization, Project}; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use itertools::Itertools; use redis::cmd; use rust_decimal::Decimal; @@ -14,7 +14,8 @@ pub struct TeamBuilder { pub struct TeamMemberBuilder { pub user_id: UserId, pub role: String, - pub permissions: Permissions, + pub permissions: ProjectPermissions, + pub organization_permissions: Option, pub accepted: bool, pub payouts_split: Decimal, pub ordering: i64, @@ -41,30 +42,20 @@ impl TeamBuilder { for member in self.members { let team_member_id = generate_team_member_id(&mut *transaction).await?; - let team_member = TeamMember { - id: team_member_id, - team_id, - user_id: member.user_id, - role: member.role, - permissions: member.permissions, - accepted: member.accepted, - payouts_split: member.payouts_split, - ordering: member.ordering, - }; - sqlx::query!( " - INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO team_members (id, team_id, user_id, role, permissions, organization_permissions, accepted, payouts_split, ordering) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ", - team_member.id as TeamMemberId, - team_member.team_id as TeamId, - team_member.user_id as UserId, - team_member.role, - team_member.permissions.bits() as i64, - team_member.accepted, - team_member.payouts_split, - team_member.ordering, + team_member_id as TeamMemberId, + team.id as TeamId, + member.user_id as UserId, + member.role, + member.permissions.bits() as i64, + member.organization_permissions.map(|p| p.bits() as i64), + member.accepted, + member.payouts_split, + member.ordering, ) .execute(&mut *transaction) .await?; @@ -80,15 +71,70 @@ pub struct Team { pub id: TeamId, } +#[derive(Deserialize, Serialize, Clone, Debug, Copy)] +pub enum TeamAssociationId { + Project(ProjectId), + Organization(OrganizationId), +} + +impl Team { + pub async fn get_association<'a, 'b, E>( + id: TeamId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT m.id AS pid, NULL AS oid + FROM mods m + WHERE m.team_id = $1 + + UNION ALL + + SELECT NULL AS pid, o.id AS oid + FROM organizations o + WHERE o.team_id = $1 + ", + id as TeamId + ) + .fetch_optional(executor) + .await?; + + if let Some(t) = result { + // Only one of project_id or organization_id will be set + let mut team_association_id = None; + if let Some(pid) = t.pid { + team_association_id = Some(TeamAssociationId::Project(ProjectId(pid))); + } + if let Some(oid) = t.oid { + team_association_id = Some(TeamAssociationId::Organization(OrganizationId(oid))); + } + return Ok(team_association_id); + } + Ok(None) + } +} + /// A member of a team -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct TeamMember { pub id: TeamMemberId, pub team_id: TeamId, + /// The ID of the user associated with the member pub user_id: UserId, pub role: String, - pub permissions: Permissions, + + // The permissions of the user in this project team + // For an organization team, these are the fallback permissions for any project in the organization + pub permissions: ProjectPermissions, + + // The permissions of the user in this organization team + // For a project team, this is None + pub organization_permissions: Option, + pub accepted: bool, pub payouts_split: Decimal, pub ordering: i64, @@ -154,31 +200,34 @@ impl TeamMember { if !team_ids_parsed.is_empty() { let teams: Vec = sqlx::query!( " - SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering, - tm.user_id user_id - FROM team_members tm - WHERE tm.team_id = ANY($1) - ORDER BY tm.team_id, tm.ordering + SELECT id, team_id, role AS member_role, permissions, organization_permissions, + accepted, payouts_split, + ordering, user_id + FROM team_members + WHERE team_id = ANY($1) + ORDER BY team_id, ordering; ", &team_ids_parsed ) - .fetch_many(exec) - .try_filter_map(|e| async { - Ok(e.right().map(|m| - TeamMember { - id: TeamMemberId(m.id), - team_id: TeamId(m.team_id), - role: m.member_role, - permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), - accepted: m.accepted, - user_id: UserId(m.user_id), - payouts_split: m.payouts_split, - ordering: m.ordering, - } - )) - }) - .try_collect::>() - .await?; + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|m| TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + role: m.member_role, + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), + accepted: m.accepted, + user_id: UserId(m.user_id), + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + }) + .try_collect::>() + .await?; for (id, members) in &teams.into_iter().group_by(|x| x.team_id) { let mut members = members.collect::>(); @@ -240,7 +289,9 @@ impl TeamMember { let team_members = sqlx::query!( " - SELECT id, team_id, user_id, role, permissions, accepted, payouts_split, ordering + SELECT id, team_id, role AS member_role, permissions, organization_permissions, + accepted, payouts_split, role, + ordering, user_id FROM team_members WHERE (team_id = ANY($1) AND user_id = $2 AND accepted = TRUE) ORDER BY ordering @@ -256,7 +307,11 @@ impl TeamMember { team_id: TeamId(m.team_id), user_id, role: m.role, - permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), accepted: m.accepted, payouts_split: m.payouts_split, ordering: m.ordering, @@ -286,9 +341,13 @@ impl TeamMember { { let result = sqlx::query!( " - SELECT id, user_id, role, permissions, accepted, payouts_split, ordering + SELECT id, team_id, role AS member_role, permissions, organization_permissions, + accepted, payouts_split, role, + ordering, user_id + FROM team_members WHERE (team_id = $1 AND user_id = $2) + ORDER BY ordering ", id as TeamId, user_id as UserId @@ -302,7 +361,11 @@ impl TeamMember { team_id: id, user_id, role: m.role, - permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), accepted: m.accepted, payouts_split: m.payouts_split, ordering: m.ordering, @@ -319,10 +382,10 @@ impl TeamMember { sqlx::query!( " INSERT INTO team_members ( - id, team_id, user_id, role, permissions, accepted + id, team_id, user_id, role, permissions, organization_permissions, accepted ) VALUES ( - $1, $2, $3, $4, $5, $6 + $1, $2, $3, $4, $5, $6, $7 ) ", self.id as TeamMemberId, @@ -330,6 +393,7 @@ impl TeamMember { self.user_id as UserId, self.role, self.permissions.bits() as i64, + self.organization_permissions.map(|p| p.bits() as i64), self.accepted, ) .execute(&mut *transaction) @@ -362,7 +426,8 @@ impl TeamMember { pub async fn edit_team_member( id: TeamId, user_id: UserId, - new_permissions: Option, + new_permissions: Option, + new_organization_permissions: Option, new_role: Option, new_accepted: Option, new_payouts_split: Option, @@ -384,6 +449,21 @@ impl TeamMember { .await?; } + if let Some(organization_permissions) = new_organization_permissions { + sqlx::query!( + " + UPDATE team_members + SET organization_permissions = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + organization_permissions.bits() as i64, + id as TeamId, + user_id as UserId, + ) + .execute(&mut *transaction) + .await?; + } + if let Some(role) = new_role { sqlx::query!( " @@ -458,7 +538,8 @@ impl TeamMember { { let result = sqlx::query!( " - SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM mods m + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering + FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE WHERE m.id = $1 ", @@ -474,7 +555,52 @@ impl TeamMember { team_id: TeamId(m.team_id), user_id, role: m.role, - permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + } else { + Ok(None) + } + } + + pub async fn get_from_user_id_organization<'a, 'b, E>( + id: OrganizationId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering + FROM organizations o + INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = TRUE + WHERE o.id = $1 + ", + id as OrganizationId, + user_id as UserId + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id, + role: m.role, + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), accepted: m.accepted, payouts_split: m.payouts_split, ordering: m.ordering, @@ -494,7 +620,8 @@ impl TeamMember { { let result = sqlx::query!( " - SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM versions v + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id + FROM versions v INNER JOIN mods m ON m.id = v.mod_id INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE WHERE v.id = $1 @@ -511,7 +638,11 @@ impl TeamMember { team_id: TeamId(m.team_id), user_id, role: m.role, - permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), accepted: m.accepted, payouts_split: m.payouts_split, ordering: m.ordering, @@ -520,4 +651,30 @@ impl TeamMember { Ok(None) } } + + // Gets both required members for checking permissions of an action on a project + // - project team member (a user's membership to a given project) + // - organization team member (a user's membership to a given organization that owns a given project) + pub async fn get_for_project_permissions<'a, 'b, E>( + project: &Project, + user_id: UserId, + executor: E, + ) -> Result<(Option, Option), super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let project_team_member = + Self::get_from_user_id(project.team_id, user_id, executor).await?; + + let organization = + Organization::get_associated_organization_project_id(project.id, executor).await?; + + let organization_team_member = if let Some(organization) = &organization { + Self::get_from_user_id(organization.team_id, user_id, executor).await? + } else { + None + }; + + Ok((project_team_member, organization_team_member)) + } } diff --git a/src/models/ids.rs b/src/models/ids.rs index 0d4682e6..20166b79 100644 --- a/src/models/ids.rs +++ b/src/models/ids.rs @@ -3,6 +3,7 @@ use thiserror::Error; pub use super::collections::CollectionId; pub use super::images::ImageId; pub use super::notifications::NotificationId; +pub use super::organizations::OrganizationId; pub use super::pats::PatId; pub use super::projects::{ProjectId, VersionId}; pub use super::reports::ReportId; @@ -113,6 +114,7 @@ base62_id_impl!(UserId, UserId); base62_id_impl!(VersionId, VersionId); base62_id_impl!(CollectionId, CollectionId); base62_id_impl!(TeamId, TeamId); +base62_id_impl!(OrganizationId, OrganizationId); base62_id_impl!(ReportId, ReportId); base62_id_impl!(NotificationId, NotificationId); base62_id_impl!(ThreadId, ThreadId); diff --git a/src/models/mod.rs b/src/models/mod.rs index 47312a2d..e1d4ace9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,6 +4,7 @@ pub mod error; pub mod ids; pub mod images; pub mod notifications; +pub mod organizations; pub mod pack; pub mod pats; pub mod projects; diff --git a/src/models/notifications.rs b/src/models/notifications.rs index 7f848df9..eda1c05c 100644 --- a/src/models/notifications.rs +++ b/src/models/notifications.rs @@ -1,4 +1,5 @@ use super::ids::Base62Id; +use super::ids::OrganizationId; use super::users::UserId; use crate::database::models::notification_item::Notification as DBNotification; use crate::database::models::notification_item::NotificationAction as DBNotificationAction; @@ -42,6 +43,12 @@ pub enum NotificationBody { invited_by: UserId, role: String, }, + OrganizationInvite { + organization_id: OrganizationId, + invited_by: UserId, + team_id: TeamId, + role: String, + }, StatusChange { project_id: ProjectId, old_status: ProjectStatus, @@ -105,6 +112,36 @@ impl From for Notification { }, ], ), + NotificationBody::OrganizationInvite { + organization_id, + role, + team_id, + .. + } => ( + Some("organization_invite".to_string()), + "You have been invited to join an organization!".to_string(), + format!( + "An invite has been sent for you to be {} of an organization", + role + ), + format!("/organization/{}", organization_id), + vec![ + NotificationAction { + title: "Accept".to_string(), + action_route: ("POST".to_string(), format!("team/{team_id}/join")), + }, + NotificationAction { + title: "Deny".to_string(), + action_route: ( + "DELETE".to_string(), + format!( + "organization/{organization_id}/members/{}", + UserId::from(notif.user_id) + ), + ), + }, + ], + ), NotificationBody::StatusChange { old_status, new_status, diff --git a/src/models/organizations.rs b/src/models/organizations.rs new file mode 100644 index 00000000..6163ddee --- /dev/null +++ b/src/models/organizations.rs @@ -0,0 +1,49 @@ +use super::{ + ids::{Base62Id, TeamId}, + teams::TeamMember, +}; +use serde::{Deserialize, Serialize}; + +/// The ID of a team +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct OrganizationId(pub u64); + +/// An organization of users who control a project +#[derive(Serialize, Deserialize)] +pub struct Organization { + /// The id of the organization + pub id: OrganizationId, + /// The title (and slug) of the organization + pub title: String, + /// The associated team of the organization + pub team_id: TeamId, + /// The description of the organization + pub description: String, + + /// The icon url of the organization + pub icon_url: Option, + /// The color of the organization (picked from the icon) + pub color: Option, + + /// A list of the members of the organization + pub members: Vec, +} + +impl Organization { + pub fn from( + data: crate::database::models::organization_item::Organization, + team_members: Vec, + ) -> Self { + Self { + id: data.id.into(), + title: data.title, + team_id: data.team_id.into(), + description: data.description, + members: team_members, + icon_url: data.icon_url, + color: data.color, + } + } +} diff --git a/src/models/pats.rs b/src/models/pats.rs index 6a906a76..313a7614 100644 --- a/src/models/pats.rs +++ b/src/models/pats.rs @@ -94,8 +94,17 @@ bitflags::bitflags! { // delete a collection const COLLECTION_DELETE = 1 << 34; - const ALL = 0b11111111111111111111111111111111111; - const NOT_RESTRICTED = 0b111100000011111111111111100111; + // create an organization + const ORGANIZATION_CREATE = 1 << 35; + // read a user's organizations + const ORGANIZATION_READ = 1 << 36; + // write to an organization + const ORGANIZATION_WRITE = 1 << 37; + // delete an organization + const ORGANIZATION_DELETE = 1 << 38; + + const ALL = 0b111111111111111111111111111111111111111; + const NOT_RESTRICTED = 0b1111111100000011111111111111100111; const NONE = 0b0; } } diff --git a/src/models/projects.rs b/src/models/projects.rs index 35f38fb0..716f8ac5 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -1,4 +1,4 @@ -use super::ids::Base62Id; +use super::ids::{Base62Id, OrganizationId}; use super::teams::TeamId; use super::users::UserId; use crate::database::models::project_item::QueryProject; @@ -31,6 +31,8 @@ pub struct Project { pub project_type: String, /// The team of people that has ownership of this project. pub team: TeamId, + /// The optional organization of people that have ownership of this project. + pub organization: Option, /// The title or name of the project. pub title: String, /// A short description of the project. @@ -120,6 +122,7 @@ impl From for Project { slug: m.slug, project_type: data.project_type, team: m.team_id.into(), + organization: m.organization_id.map(|i| i.into()), title: m.title, description: m.description, body: m.body, diff --git a/src/models/teams.rs b/src/models/teams.rs index d5c5494f..046a1f6b 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -24,7 +24,7 @@ pub struct Team { bitflags::bitflags! { #[derive(Serialize, Deserialize)] #[serde(transparent)] - pub struct Permissions: u64 { + pub struct ProjectPermissions: u64 { const UPLOAD_VERSION = 1 << 0; const DELETE_VERSION = 1 << 1; const EDIT_DETAILS = 1 << 2; @@ -40,29 +40,89 @@ bitflags::bitflags! { } } -impl Default for Permissions { - fn default() -> Permissions { - Permissions::UPLOAD_VERSION | Permissions::DELETE_VERSION +impl Default for ProjectPermissions { + fn default() -> ProjectPermissions { + ProjectPermissions::UPLOAD_VERSION | ProjectPermissions::DELETE_VERSION } } -impl Permissions { +impl ProjectPermissions { pub fn get_permissions_by_role( role: &crate::models::users::Role, - team_member: &Option, + project_team_member: &Option, // team member of the user in the project + organization_team_member: &Option, // team member of the user in the organization ) -> Option { if role.is_admin() { - Some(Permissions::ALL) - } else if let Some(member) = team_member { - Some(member.permissions) - } else if role.is_mod() { - Some(Permissions::EDIT_DETAILS | Permissions::EDIT_BODY | Permissions::UPLOAD_VERSION) + return Some(ProjectPermissions::ALL); + } + + if let Some(member) = project_team_member { + return Some(member.permissions); + } + + if let Some(member) = organization_team_member { + return Some(member.permissions); // Use default project permissions for the organization team member + } + + if role.is_mod() { + Some( + ProjectPermissions::EDIT_DETAILS + | ProjectPermissions::EDIT_BODY + | ProjectPermissions::UPLOAD_VERSION, + ) } else { None } } } +bitflags::bitflags! { + #[derive(Serialize, Deserialize)] + #[serde(transparent)] + pub struct OrganizationPermissions: u64 { + const EDIT_DETAILS = 1 << 0; + const EDIT_BODY = 1 << 1; + const MANAGE_INVITES = 1 << 2; + const REMOVE_MEMBER = 1 << 3; + const EDIT_MEMBER = 1 << 4; + const ADD_PROJECT = 1 << 5; + const REMOVE_PROJECT = 1 << 6; + const DELETE_ORGANIZATION = 1 << 8; + const EDIT_MEMBER_DEFAULT_PERMISSIONS = 1 << 9; // Separate from EDIT_MEMBER + const ALL = 0b1111111111; + const NONE = 0b0; + } +} + +impl Default for OrganizationPermissions { + fn default() -> OrganizationPermissions { + OrganizationPermissions::NONE + } +} + +impl OrganizationPermissions { + pub fn get_permissions_by_role( + role: &crate::models::users::Role, + team_member: &Option, + ) -> Option { + if role.is_admin() { + return Some(OrganizationPermissions::ALL); + } + + if let Some(member) = team_member { + return member.organization_permissions; + } + if role.is_mod() { + return Some( + OrganizationPermissions::EDIT_DETAILS + | OrganizationPermissions::EDIT_BODY + | OrganizationPermissions::ADD_PROJECT, + ); + } + None + } +} + /// A member of a team #[derive(Serialize, Deserialize, Clone)] pub struct TeamMember { @@ -72,8 +132,16 @@ pub struct TeamMember { pub user: User, /// The role of the user in the team pub role: String, - /// A bitset containing the user's permissions in this team - pub permissions: Option, + /// A bitset containing the user's permissions in this team. + /// In an organization-controlled project, these are the unique overriding permissions for the user's role for any project in the organization, if they exist. + /// In an organization, these are the default project permissions for any project in the organization. + /// Not optional- only None if they are being hidden from the user. + pub permissions: Option, + + /// A bitset containing the user's permissions in this organization. + /// In a project team, this is None. + pub organization_permissions: Option, + /// Whether the user has joined the team or is just invited to it pub accepted: bool, @@ -92,7 +160,17 @@ impl TeamMember { override_permissions: bool, ) -> Self { let user: User = user.into(); + Self::from_model(data, user, override_permissions) + } + // Use the User model directly instead of the database model, + // if already available. + // (Avoids a db query in some cases) + pub fn from_model( + data: crate::database::models::team_item::TeamMember, + user: crate::models::users::User, + override_permissions: bool, + ) -> Self { Self { team_id: data.team_id.into(), user, @@ -102,6 +180,11 @@ impl TeamMember { } else { Some(data.permissions) }, + organization_permissions: if override_permissions { + None + } else { + data.organization_permissions + }, accepted: data.accepted, payouts_split: if override_permissions { None diff --git a/src/routes/v2/analytics_get.rs b/src/routes/v2/analytics_get.rs index 666259a1..d09932a9 100644 --- a/src/routes/v2/analytics_get.rs +++ b/src/routes/v2/analytics_get.rs @@ -1,3 +1,4 @@ +use super::ApiError; use actix_web::{get, web, HttpRequest, HttpResponse}; use chrono::{Duration, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; @@ -17,8 +18,6 @@ use crate::{ queue::session::AuthQueue, }; -use super::ApiError; - pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("analytics") diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index 62595f80..622f2cab 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -4,6 +4,7 @@ mod collections; mod images; mod moderation; mod notifications; +mod organizations; pub(crate) mod project_creation; mod projects; mod reports; @@ -30,6 +31,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(crate::auth::pats::config) .configure(moderation::config) .configure(notifications::config) + .configure(organizations::config) //.configure(pats::config) .configure(project_creation::config) .configure(collections::config) diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs new file mode 100644 index 00000000..427fe5ee --- /dev/null +++ b/src/routes/v2/organizations.rs @@ -0,0 +1,924 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::{filter_authorized_projects, get_user_from_headers}; +use crate::database::models::team_item::TeamMember; +use crate::database::models::{generate_organization_id, team_item, Organization}; +use crate::file_hosting::FileHost; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::organizations::OrganizationId; +use crate::models::pats::Scopes; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; +use crate::queue::session::AuthQueue; +use crate::routes::v2::project_creation::CreateError; +use crate::routes::ApiError; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(organizations_get).service(organization_create); + cfg.service( + web::scope("organization") + .service(organization_get) + .service(organizations_edit) + .service(organization_delete) + .service(organization_projects_get) + .service(organization_projects_add) + .service(organization_projects_remove) + .service(organization_icon_edit) + .service(delete_organization_icon) + .service(super::teams::team_members_get_organization), + ); +} + +#[derive(Deserialize, Validate)] +pub struct NewOrganization { + #[validate(length(min = 3, max = 256))] + pub description: String, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + // Title of the organization, also used as slug + pub title: String, + #[serde(default = "crate::models::teams::ProjectPermissions::default")] + pub default_project_permissions: ProjectPermissions, +} + +#[post("organization")] +pub async fn organization_create( + req: HttpRequest, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_CREATE]), + ) + .await? + .1; + + new_organization + .validate() + .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; + + let mut transaction = pool.begin().await?; + + // Try title + let title_organization_id_option: Option = + serde_json::from_str(&format!("\"{}\"", new_organization.title)).ok(); + let mut organization_strings = vec![]; + if let Some(title_organization_id) = title_organization_id_option { + organization_strings.push(title_organization_id.to_string()); + } + organization_strings.push(new_organization.title.clone()); + let results = Organization::get_many(&organization_strings, &mut *transaction, &redis).await?; + if !results.is_empty() { + return Err(CreateError::SlugCollision); + } + + let organization_id = generate_organization_id(&mut transaction).await?; + + // Create organization managerial team + let team = team_item::TeamBuilder { + members: vec![team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + role: crate::models::teams::OWNER_ROLE.to_owned(), + permissions: ProjectPermissions::ALL, + organization_permissions: Some(OrganizationPermissions::ALL), + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }], + }; + let team_id = team.insert(&mut transaction).await?; + + // Create organization + let organization = Organization { + id: organization_id, + title: new_organization.title.clone(), + description: new_organization.description.clone(), + team_id, + icon_url: None, + color: None, + }; + organization.clone().insert(&mut transaction).await?; + transaction.commit().await?; + + // Only member is the owner, the logged in one + let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis) + .await? + .into_iter() + .next(); + let members_data = if let Some(member_data) = member_data { + vec![crate::models::teams::TeamMember::from_model( + member_data, + current_user.clone(), + false, + )] + } else { + return Err(CreateError::InvalidInput( + "Failed to get created team.".to_owned(), // should never happen + )); + }; + + let organization = models::organizations::Organization::from(organization, members_data); + + Ok(HttpResponse::Ok().json(organization)) +} + +#[get("{id}")] +pub async fn organization_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let organization_data = Organization::get(&id, &**pool, &redis).await?; + if let Some(data) = organization_data { + let members_data = TeamMember::get_from_team_full(data.team_id, &**pool, &redis).await?; + + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = models::organizations::Organization::from(data, team_members); + return Ok(HttpResponse::Ok().json(organization)); + } + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize)] +pub struct OrganizationIds { + pub ids: String, +} +#[get("organizations")] +pub async fn organizations_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let organizations_data = Organization::get_many(&ids, &**pool, &redis).await?; + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let mut organizations = vec![]; + + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = models::organizations::Organization::from(data, team_members); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct OrganizationEdit { + #[validate(length(min = 3, max = 256))] + pub description: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + // Title of the organization, also used as slug + pub title: Option, + pub default_project_permissions: Option, +} + +#[patch("{id}")] +pub async fn organizations_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + new_organization + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let string = info.into_inner().0; + let result = database::models::Organization::get(&string, &**pool, &redis).await?; + if let Some(organization_item) = result { + let id = organization_item.id; + + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member); + + if let Some(perms) = permissions { + let mut transaction = pool.begin().await?; + if let Some(description) = &new_organization.description { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description of this organization!" + .to_string(), + )); + } + sqlx::query!( + " + UPDATE organizations + SET description = $1 + WHERE (id = $2) + ", + description, + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(title) = &new_organization.title { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the title of this organization!" + .to_string(), + )); + } + + let title_organization_id_option: Option = parse_base62(title).ok(); + if let Some(title_organization_id) = title_organization_id_option { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1) + ", + title_organization_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Title collides with other organization's id!".to_string(), + )); + } + } + + // Make sure the new title is different from the old one + // We are able to unwrap here because the title is always set + if !title.eq(&organization_item.title.clone()) { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1)) + ", + title + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Title collides with other organization's id!".to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE organizations + SET title = LOWER($1) + WHERE (id = $2) + ", + Some(title), + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this organization!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[delete("{id}")] +pub async fn organization_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_DELETE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_admin() { + let team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &Some(team_member)) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this organization!".to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let result = + database::models::Organization::remove(organization.id, &mut transaction, &redis).await?; + + transaction.commit().await?; + + database::models::Organization::clear_cache(organization.id, Some(organization.title), &redis) + .await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[get("{id}/projects")] +pub async fn organization_projects_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let possible_organization_id: Option = parse_base62(&info).ok(); + use futures::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT m.id FROM organizations o + LEFT JOIN mods m ON m.id = o.id + WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL) + ", + possible_organization_id.map(|x| x as i64), + info + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { Ok(e.right().map(|m| crate::database::models::ProjectId(m.id))) }) + .try_collect::>() + .await?; + + let projects_data = + crate::database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; + + let projects = filter_authorized_projects(projects_data, ¤t_user, &pool).await?; + Ok(HttpResponse::Ok().json(projects)) +} + +#[derive(Deserialize)] +pub struct OrganizationProjectAdd { + pub project_id: String, // Also allow title/slug +} +#[post("{id}/projects")] +pub async fn organization_projects_add( + req: HttpRequest, + info: web::Path<(String,)>, + project_info: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = database::models::Organization::get(&info, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let project_item = database::models::Project::get(&project_info.project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "The specified project is already owned by an organization!".to_string(), + )); + } + + let project_team_member = database::models::TeamMember::get_from_user_id_project( + project_item.inner.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| ApiError::InvalidInput("You are not a member of this project!".to_string()))?; + + let organization_team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("You are not a member of this organization!".to_string()) + })?; + + // Require ownership of a project to add it to an organization + if !current_user.role.is_admin() + && !project_team_member + .role + .eq(crate::models::teams::OWNER_ROLE) + { + return Err(ApiError::CustomAuthentication( + "You need to be an owner of a project to add it to an organization!".to_string(), + )); + } + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::ADD_PROJECT) { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE mods + SET organization_id = $1 + WHERE (id = $2) + ", + organization.id as database::models::OrganizationId, + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!".to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +#[delete("{organization_id}/projects/{project_id}")] +pub async fn organization_projects_remove( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (organization_id, project_id) = info.into_inner(); + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = database::models::Organization::get(&organization_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let project_item = database::models::Project::get(&project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !project_item + .inner + .organization_id + .eq(&Some(organization.id)) + { + return Err(ApiError::InvalidInput( + "The specified project is not owned by this organization!".to_string(), + )); + } + + let organization_team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("You are not a member of this organization!".to_string()) + })?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE mods + SET organization_id = NULL + WHERE (id = $1) + ", + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!".to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn organization_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon.".to_string(), + )); + } + } + + if let Some(icon) = organization_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let organization_id: OrganizationId = organization_item.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", organization_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for project icon: {}", + ext.ext + ))) + } +} + +#[delete("{id}/icon")] +pub async fn delete_organization_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon.".to_string(), + )); + } + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = organization_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 2b5e879e..a0e057d8 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -11,6 +11,7 @@ use crate::models::projects::{ DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, VersionStatus, }; +use crate::models::teams::ProjectPermissions; use crate::models::threads::ThreadType; use crate::models::users::UserId; use crate::queue::session::AuthQueue; @@ -240,6 +241,9 @@ struct ProjectCreateData { #[validate(length(max = 10))] #[serde(default)] pub uploaded_images: Vec, + + /// The id of the organization to create the project in + pub organization_id: Option, } #[derive(Serialize, Deserialize, Validate, Clone)] @@ -667,7 +671,9 @@ async fn project_create_inner( members: vec![models::team_item::TeamMemberBuilder { user_id: current_user.id.into(), role: crate::models::teams::OWNER_ROLE.to_owned(), - permissions: crate::models::teams::Permissions::ALL, + // Allow all permissions for project creator, even if attached to a project + permissions: ProjectPermissions::all(), + organization_permissions: None, accepted: true, payouts_split: Decimal::ONE_HUNDRED, ordering: 0, @@ -745,6 +751,7 @@ async fn project_create_inner( project_id: project_id.into(), project_type_id, team_id, + organization_id: project_create_data.organization_id, title: project_create_data.title, description: project_create_data.description, body: project_create_data.body, @@ -834,6 +841,7 @@ async fn project_create_inner( slug: project_builder.slug.clone(), project_type: project_create_data.project_type.clone(), team: team_id.into(), + organization: project_create_data.organization_id.map(|x| x.into()), title: project_builder.title.clone(), description: project_builder.description.clone(), body: project_builder.body.clone(), diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 4a2b2e54..4d30b207 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -12,7 +12,7 @@ use crate::models::pats::Scopes; use crate::models::projects::{ DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, }; -use crate::models::teams::Permissions; +use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -404,20 +404,25 @@ pub async fn project_edit( if let Some(project_item) = result { let id = project_item.inner.id; - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, - ) - .await?; + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; - let permissions = Permissions::get_permissions_by_role(&user.role, &team_member); + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); if let Some(perms) = permissions { let mut transaction = pool.begin().await?; if let Some(title) = &new_project.title { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the title of this project!" .to_string(), @@ -438,7 +443,7 @@ pub async fn project_edit( } if let Some(description) = &new_project.description { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the description of this project!" .to_string(), @@ -459,7 +464,7 @@ pub async fn project_edit( } if let Some(status) = &new_project.status { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the status of this project!" .to_string(), @@ -624,7 +629,7 @@ pub async fn project_edit( } if let Some(requested_status) = &new_project.requested_status { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the requested status of this project!" .to_string(), @@ -653,7 +658,7 @@ pub async fn project_edit( .await?; } - if perms.contains(Permissions::EDIT_DETAILS) { + if perms.contains(ProjectPermissions::EDIT_DETAILS) { if new_project.categories.is_some() { sqlx::query!( " @@ -680,7 +685,7 @@ pub async fn project_edit( } if let Some(categories) = &new_project.categories { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the categories of this project!" .to_string(), @@ -712,7 +717,7 @@ pub async fn project_edit( } if let Some(categories) = &new_project.additional_categories { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the additional categories of this project!" .to_string(), @@ -744,7 +749,7 @@ pub async fn project_edit( } if let Some(issues_url) = &new_project.issues_url { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the issues URL of this project!" .to_string(), @@ -765,7 +770,7 @@ pub async fn project_edit( } if let Some(source_url) = &new_project.source_url { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the source URL of this project!" .to_string(), @@ -786,7 +791,7 @@ pub async fn project_edit( } if let Some(wiki_url) = &new_project.wiki_url { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the wiki URL of this project!" .to_string(), @@ -807,7 +812,7 @@ pub async fn project_edit( } if let Some(license_url) = &new_project.license_url { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the license URL of this project!" .to_string(), @@ -828,7 +833,7 @@ pub async fn project_edit( } if let Some(discord_url) = &new_project.discord_url { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the discord URL of this project!" .to_string(), @@ -849,7 +854,7 @@ pub async fn project_edit( } if let Some(slug) = &new_project.slug { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the slug of this project!" .to_string(), @@ -907,7 +912,7 @@ pub async fn project_edit( } if let Some(new_side) = &new_project.client_side { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the side type of this mod!" .to_string(), @@ -935,7 +940,7 @@ pub async fn project_edit( } if let Some(new_side) = &new_project.server_side { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the side type of this project!" .to_string(), @@ -963,7 +968,7 @@ pub async fn project_edit( } if let Some(license) = &new_project.license_id { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the license of this project!" .to_string(), @@ -994,7 +999,7 @@ pub async fn project_edit( } if let Some(donations) = &new_project.donation_urls { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the donation links of this project!" .to_string(), @@ -1086,7 +1091,7 @@ pub async fn project_edit( } if let Some(body) = &new_project.body { - if !perms.contains(Permissions::EDIT_BODY) { + if !perms.contains(ProjectPermissions::EDIT_BODY) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the body of this project!" .to_string(), @@ -1107,7 +1112,7 @@ pub async fn project_edit( } if let Some(monetization_status) = &new_project.monetization_status { - if !perms.contains(Permissions::EDIT_DETAILS) { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the monetization status of this project!" .to_string(), @@ -1282,6 +1287,24 @@ pub async fn projects_edit( let team_members = database::models::TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = + database::models::Organization::get_many_ids(&organization_ids, &**pool, &redis).await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = database::models::TeamMember::get_from_team_full_many( + &organization_team_ids, + &**pool, + &redis, + ) + .await?; + let categories = database::models::categories::Category::list(&**pool, &redis).await?; let donation_platforms = database::models::categories::DonationPlatform::list(&**pool, &redis).await?; @@ -1290,11 +1313,32 @@ pub async fn projects_edit( for project in projects_data { if !user.role.is_mod() { - if let Some(member) = team_members + let team_member = team_members .iter() - .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()) - { - if !member.permissions.contains(Permissions::EDIT_DETAILS) { + .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = if let Some(organization) = organization { + organization_team_members + .iter() + .find(|x| x.team_id == organization.team_id && x.user_id == user.id.into()) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + if team_member.is_some() { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication(format!( "You do not have the permissions to bulk edit project {}!", project.inner.title @@ -1608,18 +1652,22 @@ pub async fn project_schedule( let result = database::models::Project::get(&string, &**pool, &redis).await?; if let Some(project_item) = result { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.clone(), + &organization_team_member.clone(), ) - .await?; + .unwrap_or_default(); - if !user.role.is_mod() - && !team_member - .map(|x| x.permissions.contains(Permissions::EDIT_DETAILS)) - .unwrap_or(false) - { + if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have permission to edit this project's scheduling data!".to_string(), )); @@ -1701,18 +1749,29 @@ pub async fn project_icon_edit( })?; if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member.permissions.contains(Permissions::EDIT_DETAILS) { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this project's icon.".to_string(), )); @@ -1803,18 +1862,28 @@ pub async fn delete_project_icon( })?; if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member.permissions.contains(Permissions::EDIT_DETAILS) { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this project's icon.".to_string(), )); @@ -1908,18 +1977,29 @@ pub async fn add_gallery_item( } if !user.role.is_admin() { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member.permissions.contains(Permissions::EDIT_DETAILS) { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this project's gallery.".to_string(), )); @@ -2050,18 +2130,28 @@ pub async fn edit_gallery_item( })?; if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member.permissions.contains(Permissions::EDIT_DETAILS) { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this project's gallery.".to_string(), )); @@ -2201,18 +2291,29 @@ pub async fn delete_gallery_item( })?; if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - project_item.inner.team_id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member.permissions.contains(Permissions::EDIT_DETAILS) { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You don't have permission to edit this project's gallery.".to_string(), )); @@ -2296,21 +2397,29 @@ pub async fn project_delete( })?; if !user.role.is_admin() { - let team_member = database::models::TeamMember::get_from_user_id_project( - project.inner.id, - user.id.into(), - &**pool, + let (team_member, organization_team_member) = + database::models::TeamMember::get_for_project_permissions( + &project.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; + .unwrap_or_default(); - if !team_member - .permissions - .contains(Permissions::DELETE_PROJECT) - { + if !permissions.contains(ProjectPermissions::DELETE_PROJECT) { return Err(ApiError::CustomAuthentication( "You don't have permission to delete this project!".to_string(), )); diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index bc1f8cf6..34985ae3 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -1,10 +1,11 @@ use crate::auth::{get_user_from_headers, is_authorized}; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::TeamMember; -use crate::models::ids::ProjectId; +use crate::database::models::team_item::TeamAssociationId; +use crate::database::models::{Organization, Team, TeamMember}; +use crate::database::Project; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; -use crate::models::teams::{Permissions, TeamId}; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId}; use crate::models::users::UserId; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -27,6 +28,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); } +// Returns all members of a project, +// including the team members of the project's team, but +// also the members of the organization's team if the project is associated with an organization +// (Unlike team_members_get_project, which only returns the members of the project's team) #[get("{id}/members")] pub async fn team_members_get_project( req: HttpRequest, @@ -53,9 +58,85 @@ pub async fn team_members_get_project( if !is_authorized(&project.inner, ¤t_user, &pool).await? { return Ok(HttpResponse::NotFound().body("")); } + let mut members_data = + TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; + let mut member_user_ids = members_data.iter().map(|x| x.user_id).collect::>(); + + // Adds the organization's team members to the list of members, if the project is associated with an organization + if let Some(oid) = project.inner.organization_id { + let organization_data = Organization::get_id(oid, &**pool, &redis).await?; + if let Some(organization_data) = organization_data { + let org_team = + TeamMember::get_from_team_full(organization_data.team_id, &**pool, &redis) + .await?; + for member in org_team { + if !member_user_ids.contains(&member.user_id) { + member_user_ids.push(member.user_id); + members_data.push(member); + } + } + } + } + + let users = + crate::database::models::User::get_many_ids(&member_user_ids, &**pool, &redis).await?; + + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(team_members)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[get("{id}/members")] +pub async fn team_members_get_organization( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let organization_data = + crate::database::models::Organization::get(&string, &**pool, &redis).await?; + + if let Some(organization) = organization_data { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); let members_data = - TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; + TeamMember::get_from_team_full(organization.team_id, &**pool, &redis).await?; let users = crate::database::models::User::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, @@ -95,6 +176,7 @@ pub async fn team_members_get_project( } } +// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) #[get("{id}/members")] pub async fn team_members_get( req: HttpRequest, @@ -258,6 +340,7 @@ pub async fn join_team( current_user.id.into(), None, None, + None, Some(true), None, None, @@ -290,8 +373,10 @@ pub struct NewTeamMember { pub user_id: UserId, #[serde(default = "default_role")] pub role: String, - #[serde(default = "Permissions::default")] - pub permissions: Permissions, + #[serde(default)] + pub permissions: ProjectPermissions, + #[serde(default)] + pub organization_permissions: Option, #[serde(default)] pub payouts_split: Decimal, #[serde(default = "default_ordering")] @@ -320,23 +405,76 @@ pub async fn add_team_member( ) .await? .1; - let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool) + + let team_association = Team::get_association(team_id, &**pool) .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?; + + match team_association { + // If team is associated with a project, check if they have permissions to invite users to that project + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id(pid, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, ) - })?; + .unwrap_or_default(); - if !member.permissions.contains(Permissions::MANAGE_INVITES) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to invite users to this team".to_string(), - )); - } - if !member.permissions.contains(new_member.permissions) { - return Err(ApiError::InvalidInput( - "The new member has permissions that you don't have".to_string(), - )); + if !permissions.contains(ProjectPermissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this team".to_string(), + )); + } + if !permissions.contains(new_member.permissions) { + return Err(ApiError::InvalidInput( + "The new member has permissions that you don't have".to_string(), + )); + } + + if new_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be set" + .to_string(), + )); + } + } + // If team is associated with an organization, check if they have permissions to invite users to that organization + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + println!("{:?}", organization_permissions); + if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this organization".to_string(), + )); + } + if !organization_permissions + .contains(new_member.organization_permissions.unwrap_or_default()) + { + return Err(ApiError::InvalidInput( + "The new member has organization permissions that you don't have".to_string(), + )); + } + if !organization_permissions + .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) + && !new_member.permissions.is_empty() + { + return Err(ApiError::InvalidInput( + "You do not have permission to give this user default project permissions." + .to_string(), + )); + } + } } if new_member.role == crate::models::teams::OWNER_ROLE { @@ -365,8 +503,7 @@ pub async fn add_team_member( )); } } - - crate::database::models::User::get_id(member.user_id, &**pool, &redis) + crate::database::models::User::get_id(new_member.user_id.into(), &**pool, &redis) .await? .ok_or_else(|| ApiError::InvalidInput("An invalid User ID specified".to_string()))?; @@ -377,6 +514,7 @@ pub async fn add_team_member( user_id: new_member.user_id.into(), role: new_member.role.clone(), permissions: new_member.permissions, + organization_permissions: new_member.organization_permissions, accepted: false, payouts_split: new_member.payouts_split, ordering: new_member.ordering, @@ -384,27 +522,32 @@ pub async fn add_team_member( .insert(&mut transaction) .await?; - let result = sqlx::query!( - " - SELECT m.id - FROM mods m - WHERE m.team_id = $1 - ", - team_id as crate::database::models::ids::TeamId - ) - .fetch_one(&**pool) - .await?; - - NotificationBuilder { - body: NotificationBody::TeamInvite { - project_id: ProjectId(result.id as u64), - team_id: team_id.into(), - invited_by: current_user.id, - role: new_member.role.clone(), - }, + match team_association { + TeamAssociationId::Project(pid) => { + NotificationBuilder { + body: NotificationBody::TeamInvite { + project_id: pid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction) + .await?; + } + TeamAssociationId::Organization(oid) => { + NotificationBuilder { + body: NotificationBody::OrganizationInvite { + organization_id: oid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction) + .await?; + } } - .insert(new_member.user_id.into(), &mut transaction) - .await?; TeamMember::clear_cache(team_id, &redis).await?; @@ -415,7 +558,8 @@ pub async fn add_team_member( #[derive(Serialize, Deserialize, Clone)] pub struct EditTeamMember { - pub permissions: Option, + pub permissions: Option, + pub organization_permissions: Option, pub role: Option, pub payouts_split: Option, pub ordering: Option, @@ -443,13 +587,11 @@ pub async fn edit_team_member( ) .await? .1; - let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) + + let team_association = Team::get_association(id, &**pool) .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - ) - })?; + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; let edit_member_db = TeamMember::get_from_user_id_pending(id, user_id, &**pool) .await? .ok_or_else(|| { @@ -468,17 +610,72 @@ pub async fn edit_team_member( )); } - if !member.permissions.contains(Permissions::EDIT_MEMBER) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - )); - } + match team_association { + TeamAssociationId::Project(project_id) => { + let organization = + Organization::get_associated_organization_project_id(project_id, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member.clone(), + &organization_team_member, + ) + .unwrap_or_default(); + if !permissions.contains(ProjectPermissions::EDIT_MEMBER) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit members of this team".to_string(), + )); + } - if let Some(new_permissions) = edit_member.permissions { - if !member.permissions.contains(new_permissions) { - return Err(ApiError::InvalidInput( - "The new permissions have permissions that you don't have".to_string(), - )); + if let Some(new_permissions) = edit_member.permissions { + if !permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new permissions have permissions that you don't have".to_string(), + )); + } + } + + if edit_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be edited" + .to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + + if !organization_permissions.contains(OrganizationPermissions::EDIT_MEMBER) { + return Err(ApiError::InvalidInput( + "You don't have permission to edit organization permissions".to_string(), + )); + } + + if let Some(new_permissions) = edit_member.organization_permissions { + if !organization_permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new organization permissions have permissions that you don't have" + .to_string(), + )); + } + } + + if edit_member.permissions.is_some() + && !organization_permissions + .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) + { + return Err(ApiError::InvalidInput( + "You do not have permission to give this user default project permissions." + .to_string(), + )); + } } } @@ -500,6 +697,7 @@ pub async fn edit_team_member( id, user_id, edit_member.permissions, + edit_member.organization_permissions, edit_member.role.clone(), None, edit_member.payouts_split, @@ -541,6 +739,21 @@ pub async fn transfer_ownership( .await? .1; + // Forbid transferring ownership of a project team that is owned by an organization + // These are owned by the organization owner, and must be removed from the organization first + let pid = Team::get_association(id.into(), &**pool).await?; + if let Some(TeamAssociationId::Project(pid)) = pid { + let result = Project::get_id(pid, &**pool, &redis).await?; + if let Some(project_item) = result { + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "You cannot transfer ownership of a project team that is owend by an organization" + .to_string(), + )); + } + } + } + if !current_user.role.is_admin() { let member = TeamMember::get_from_user_id(id.into(), current_user.id.into(), &**pool) .await? @@ -575,6 +788,7 @@ pub async fn transfer_ownership( id.into(), current_user.id.into(), None, + None, Some(crate::models::teams::DEFAULT_ROLE.to_string()), None, None, @@ -586,7 +800,8 @@ pub async fn transfer_ownership( TeamMember::edit_team_member( id.into(), new_owner.user_id.into(), - Some(Permissions::ALL), + Some(ProjectPermissions::all()), + Some(OrganizationPermissions::all()), Some(crate::models::teams::OWNER_ROLE.to_string()), None, None, @@ -623,13 +838,11 @@ pub async fn remove_team_member( ) .await? .1; - let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) + + let team_association = Team::get_association(id, &**pool) .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - ) - })?; + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; let delete_member = TeamMember::get_from_user_id_pending(id, user_id, &**pool).await?; @@ -643,29 +856,101 @@ pub async fn remove_team_member( let mut transaction = pool.begin().await?; - if delete_member.accepted { - // Members other than the owner can either leave the team, or be - // removed by a member with the REMOVE_MEMBER permission. - if delete_member.user_id == member.user_id - || (member.permissions.contains(Permissions::REMOVE_MEMBER) && member.accepted) - { - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to remove a member from this team".to_string(), - )); + // Organization attached to a project this team is attached to + match team_association { + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id(pid, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, + ) + .unwrap_or_default(); + + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) + || permissions.contains(ProjectPermissions::REMOVE_MEMBER) + && member.as_ref().map(|m| m.accepted).unwrap_or(true) + // true as if the permission exists, but the member does not, they are part of an org + { + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this team" + .to_string(), + )); + } + } else if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) + || permissions.contains(ProjectPermissions::MANAGE_INVITES) + && member.as_ref().map(|m| m.accepted).unwrap_or(true) + // true as if the permission exists, but the member does not, they are part of an org + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel a team invite".to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + if let Some(member) = member { + // Organization teams requires a TeamMember, so we can 'unwrap' + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if delete_member.user_id == member.user_id + || organization_permissions + .contains(OrganizationPermissions::REMOVE_MEMBER) + && member.accepted + { + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this organization" + .to_string(), + )); + } + } else if delete_member.user_id == member.user_id + || organization_permissions + .contains(OrganizationPermissions::MANAGE_INVITES) + && member.accepted + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel an organization invite" + .to_string(), + )); + } + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this organization" + .to_string(), + )); + } } - } else if delete_member.user_id == member.user_id - || (member.permissions.contains(Permissions::MANAGE_INVITES) && member.accepted) - { - // This is a pending invite rather than a member, so the - // user being invited or team members with the MANAGE_INVITES - // permission can remove it. - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to cancel a team invite".to_string(), - )); } TeamMember::clear_cache(id, &redis).await?; diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 7a398e2f..af3375de 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -4,7 +4,7 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, }; -use crate::database::models::{self, image_item}; +use crate::database::models::{self, image_item, Organization}; use crate::file_hosting::FileHost; use crate::models::images::{Image, ImageContext, ImageId}; use crate::models::notifications::NotificationBody; @@ -14,7 +14,7 @@ use crate::models::projects::{ Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus, VersionType, }; -use crate::models::teams::Permissions; +use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -215,17 +215,34 @@ async fn version_create_inner( user.id.into(), &mut *transaction, ) - .await? - .ok_or_else(|| { - CreateError::CustomAuthenticationError( - "You don't have permission to upload this version!".to_string(), + .await?; + + // Get organization attached, if exists, and the member project permissions + let organization = models::Organization::get_associated_organization_project_id( + project_id, + &mut *transaction, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut *transaction, ) - })?; + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); - if !team_member - .permissions - .contains(Permissions::UPLOAD_VERSION) - { + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { return Err(CreateError::CustomAuthenticationError( "You don't have permission to upload this version!".to_string(), )); @@ -572,17 +589,33 @@ async fn upload_file_to_version_inner( user.id.into(), &mut *transaction, ) - .await? - .ok_or_else(|| { - CreateError::CustomAuthenticationError( - "You don't have permission to upload files to this version!".to_string(), + .await?; + + let organization = Organization::get_associated_organization_project_id( + version.inner.project_id, + &**client, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut *transaction, ) - })?; + .await? + } else { + None + }; - if !team_member - .permissions - .contains(Permissions::UPLOAD_VERSION) - { + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { return Err(CreateError::CustomAuthenticationError( "You don't have permission to upload files to this version!".to_string(), )); diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 569f8935..a4612e1e 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -6,7 +6,7 @@ use crate::auth::{ use crate::models::ids::VersionId; use crate::models::pats::Scopes; use crate::models::projects::VersionType; -use crate::models::teams::Permissions; +use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::{database, models}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; @@ -185,17 +185,36 @@ pub async fn delete_file( &**pool, ) .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to delete this file!".to_string(), + .map_err(ApiError::Database)?; + + let organization = + database::models::Organization::get_associated_organization_project_id( + row.project_id, + &**pool, ) - })?; + .await + .map_err(ApiError::Database)?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); - if !team_member - .permissions - .contains(Permissions::DELETE_VERSION) - { + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { return Err(ApiError::CustomAuthentication( "You don't have permission to delete this file!".to_string(), )); diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index ee949a6c..e7dce53b 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -3,13 +3,13 @@ use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; -use crate::database::models::image_item; +use crate::database::models::{image_item, Organization}; use crate::models; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; -use crate::models::teams::Permissions; +use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::img; use crate::util::validate::validation_errors_to_string; @@ -353,10 +353,31 @@ pub async fn version_edit( ) .await?; - let permissions = Permissions::get_permissions_by_role(&user.role, &team_member); + let organization = Organization::get_associated_organization_project_id( + version_item.inner.project_id, + &**pool, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); if let Some(perms) = permissions { - if !perms.contains(Permissions::UPLOAD_VERSION) { + if !perms.contains(ProjectPermissions::UPLOAD_VERSION) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit this version!".to_string(), )); @@ -754,11 +775,33 @@ pub async fn version_schedule( ) .await?; - if !user.role.is_mod() - && !team_member - .map(|x| x.permissions.contains(Permissions::EDIT_DETAILS)) - .unwrap_or(false) - { + let organization_item = + database::models::Organization::get_associated_organization_project_id( + version_item.inner.project_id, + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization_team_member = if let Some(organization) = &organization_item { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have permission to edit this version's scheduling data!".to_string(), )); @@ -819,17 +862,30 @@ pub async fn version_delete( &**pool, ) .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput( - "You do not have permission to delete versions in this team".to_string(), + .map_err(ApiError::Database)?; + + let organization = + Organization::get_associated_organization_project_id(version.inner.project_id, &**pool) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, ) - })?; + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); - if !team_member - .permissions - .contains(Permissions::DELETE_VERSION) - { + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { return Err(ApiError::CustomAuthentication( "You do not have permission to delete versions in this team".to_string(), ));