Skip to content

Commit 439e003

Browse files
Account Management API: Added new /api/v2/projects/update API route to edit project title, name, and/or description on behalf of a particular account. Updated /api/v2/projects/get to return project name field.
1 parent 5c08a79 commit 439e003

File tree

5 files changed

+209
-4
lines changed

5 files changed

+209
-4
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { z } from "../../framework";
2+
3+
import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../common";
4+
5+
import { ProjectIdSchema } from "./common";
6+
7+
// OpenAPI spec
8+
//
9+
export const UpdateProjectInputSchema = z
10+
.object({
11+
project_id: ProjectIdSchema,
12+
title: z
13+
.string()
14+
.describe(
15+
`The short title of the project. Should use no special formatting, except
16+
hashtags.`,
17+
)
18+
.optional(),
19+
description: z
20+
.string()
21+
.describe(
22+
`A longer textual description of the project. This can include hashtags and should
23+
be formatted using markdown.`,
24+
)
25+
.optional(),
26+
name: z
27+
.string()
28+
.describe(
29+
`The optional name of this project. Must be globally unique (up to case) across
30+
all projects with a given *owner*. It can be between 1 and 100 characters from
31+
a-z A-Z 0-9 period and dash.`,
32+
)
33+
.optional(),
34+
})
35+
.describe(
36+
`Update an existing project's title, name, and/or description. If the API client is an
37+
admin, they may act on any project. Otherwise, the client may only update projects
38+
for which they are listed as owners.`,
39+
);
40+
41+
export const UpdateProjectOutputSchema = z.union([
42+
FailedAPIOperationSchema,
43+
OkAPIOperationSchema,
44+
]);
45+
46+
export type UpdateProjectInput = z.infer<typeof UpdateProjectInputSchema>;
47+
export type UpdateProjectOutput = z.infer<typeof UpdateProjectOutputSchema>;

src/packages/next/pages/api/v2/accounts/set-name.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ import getAccountId from "lib/account/get-account";
99
import getParams from "lib/api/get-params";
1010

1111
import { apiRoute, apiRouteOperation } from "lib/api";
12+
import { SuccessStatus } from "lib/api/status";
1213
import {
1314
SetAccountNameInputSchema,
1415
SetAccountNameOutputSchema,
1516
} from "lib/api/schema/accounts/set-name";
1617

1718
async function handle(req, res) {
1819
try {
19-
res.json(await get(req));
20+
await get(req);
21+
res.json(SuccessStatus);
2022
} catch (err) {
2123
res.json({ error: `${err.message ? err.message : err}` });
2224
return;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
Set project title, description, etc.
3+
*/
4+
5+
import userIsInGroup from "@cocalc/server/accounts/is-in-group";
6+
import setProject from "@cocalc/server/projects/set-one";
7+
8+
import getAccountId from "lib/account/get-account";
9+
import getParams from "lib/api/get-params";
10+
11+
import { OkStatus } from "lib/api/status";
12+
import { apiRoute, apiRouteOperation } from "lib/api";
13+
import {
14+
UpdateProjectInputSchema,
15+
UpdateProjectOutputSchema,
16+
} from "lib/api/schema/projects/update";
17+
18+
async function handle(req, res) {
19+
try {
20+
await get(req);
21+
res.json(OkStatus);
22+
} catch (err) {
23+
res.json({ error: `${err.message ? err.message : err}` });
24+
return;
25+
}
26+
}
27+
28+
async function get(req) {
29+
const client_account_id = await getAccountId(req);
30+
31+
if (client_account_id == null) {
32+
throw Error("Must be signed in to update project.");
33+
}
34+
35+
const { title, description, name, project_id } = getParams(req);
36+
37+
// If the API client is an admin, they may act on any project. Otherwise, the client may
38+
// only update projects for which they are listed as owners.
39+
//
40+
const acting_account_id = (await userIsInGroup(client_account_id, "admin"))
41+
? undefined
42+
: client_account_id;
43+
44+
return setProject({
45+
acting_account_id,
46+
project_id,
47+
project_update: {
48+
title,
49+
description,
50+
name,
51+
},
52+
});
53+
}
54+
55+
export default apiRoute({
56+
update: apiRouteOperation({
57+
method: "POST",
58+
openApiOperation: {
59+
tags: ["Projects", "Admin"],
60+
},
61+
})
62+
.input({
63+
contentType: "application/json",
64+
body: UpdateProjectInputSchema,
65+
})
66+
.outputs([
67+
{
68+
status: 200,
69+
contentType: "application/json",
70+
body: UpdateProjectOutputSchema,
71+
},
72+
])
73+
.handler(handle),
74+
});

src/packages/server/projects/get.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@
77
import getPool from "@cocalc/database/pool";
88
import { isValidUUID } from "@cocalc/util/misc";
99

10+
export interface DBProject {
11+
project_id: string;
12+
title?: string;
13+
description?: string;
14+
name?: string;
15+
}
16+
1017
// I may add more fields and more options later...
1118
export default async function getProjects({
1219
account_id,
1320
limit = 50,
1421
}: {
1522
account_id: string;
1623
limit?: number;
17-
}): Promise<{ project_id: string; title?: string; description?: string }[]> {
24+
}): Promise<DBProject[]> {
1825
if (!isValidUUID(account_id)) {
1926
throw Error("account_id must be a UUIDv4");
2027
}
@@ -23,8 +30,8 @@ export default async function getProjects({
2330
}
2431
const pool = getPool();
2532
const { rows } = await pool.query(
26-
`SELECT project_id, title, description FROM projects WHERE DELETED IS NOT true AND users ? $1 AND (users#>>'{${account_id},hide}')::BOOLEAN IS NOT TRUE ORDER BY last_edited DESC LIMIT $2`,
27-
[account_id, limit]
33+
`SELECT project_id, title, description, name FROM projects WHERE DELETED IS NOT true AND users ? $1 AND (users#>>'{${account_id},hide}')::BOOLEAN IS NOT TRUE ORDER BY last_edited DESC LIMIT $2`,
34+
[account_id, limit],
2835
);
2936
return rows;
3037
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* Updates an existing project's name, title, and/or description. May be
2+
restricted such that the query is executed as though by a specific account_id.
3+
*/
4+
5+
import getPool from "@cocalc/database/pool";
6+
import { isValidUUID } from "@cocalc/util/misc";
7+
8+
import { DBProject } from "./get";
9+
10+
export default async function setProject({
11+
project_id,
12+
project_update,
13+
acting_account_id,
14+
}: {
15+
project_id: string;
16+
project_update: Omit<DBProject, "project_id">;
17+
18+
// If this parameter is NOT provided, the specified project will be updated
19+
// with NO authorization checks.
20+
//
21+
// If this parameter IS provided, this function will execute the project update query as
22+
// though the account id below had made the request; this has the effect of enforcing an
23+
// authorization check that the acting account is allowed to modify the desired project.
24+
//
25+
acting_account_id?: string;
26+
}): Promise<DBProject | undefined> {
27+
// Filter out any provided fields which are null or undefined (but allow empty strings)
28+
// and convert parameter map to an ordered array.
29+
//
30+
const updateFields = Object.entries(project_update).filter(
31+
([_, v]) => v ?? false,
32+
);
33+
34+
if (!updateFields.length) {
35+
return;
36+
}
37+
38+
// Create query param array and append project_id
39+
//
40+
const queryParams = updateFields.map(([k, v]) => v);
41+
queryParams.push(project_id);
42+
43+
const updateSubQuery = updateFields
44+
.map(([k, v], i) => `${k}=$${i + 1}`)
45+
.join(",");
46+
47+
let query = `UPDATE projects SET ${updateSubQuery} WHERE project_id=$${queryParams.length} AND deleted IS NOT TRUE`;
48+
49+
// If acting_account_id is provided, we restrict the projects which may be updated
50+
// to those for which the corresponding account is listed as an owner.
51+
//
52+
if (acting_account_id) {
53+
if (!isValidUUID(acting_account_id)) {
54+
throw Error("acting_account_id must be a UUIDv4");
55+
}
56+
57+
queryParams.push(acting_account_id);
58+
59+
// TODO: Update this to execute only on owned projects.
60+
//
61+
query += ` AND users ? $${queryParams.length} AND (users#>>'{${acting_account_id},hide}')::BOOLEAN IS NOT TRUE`;
62+
}
63+
64+
// Return updated fields
65+
//
66+
query += `RETURNING project_id, title, description, name`;
67+
68+
// Execute query
69+
//
70+
const pool = getPool();
71+
const queryResult = await pool.query(query, queryParams);
72+
console.log(queryResult);
73+
const { rows } = queryResult;
74+
return rows?.[0];
75+
}

0 commit comments

Comments
 (0)