Skip to content

Commit f1f5ff5

Browse files
authored
Merge pull request #7815 from schrodingersket/feature/7665
Account Management API: Added v2 API endpoint to delete/restore projects…
2 parents fb78afb + 26121e0 commit f1f5ff5

File tree

9 files changed

+243
-4
lines changed

9 files changed

+243
-4
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 DeleteProjectInputSchema = z
10+
.object({
11+
project_id: ProjectIdSchema,
12+
})
13+
.describe(
14+
`Deletes a specific project. This causes three operations to occur in succession.
15+
Firstly, all project licenses associated with the project are removed. Next, the
16+
project is stopped. Finally, the project's \`delete\` flag in the database is
17+
set, which removes it from the user interface. This operation may be reversed by
18+
restoring the project via the API, with the proviso that all information about
19+
applied project licenses is lost in the delete operation.`,
20+
);
21+
22+
export const DeleteProjectOutputSchema = z.union([
23+
FailedAPIOperationSchema,
24+
OkAPIOperationSchema,
25+
]);
26+
27+
export type DeleteProjectInput = z.infer<typeof DeleteProjectInputSchema>;
28+
export type DeleteProjectOutput = z.infer<typeof DeleteProjectOutputSchema>;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 RestoreProjectInputSchema = z
10+
.object({
11+
project_id: ProjectIdSchema,
12+
})
13+
.describe(
14+
`Restores a specific project from its deleted state, which clears the project's
15+
\`delete\` flag in the database and restores it to the user interface. Note that any
16+
previously applied project licenses must be re-applied to the project upon
17+
restoration.`,
18+
);
19+
20+
export const RestoreProjectOutputSchema = z.union([
21+
FailedAPIOperationSchema,
22+
OkAPIOperationSchema,
23+
]);
24+
25+
export type RestoreProjectInput = z.infer<typeof RestoreProjectInputSchema>;
26+
export type RestoreProjectOutput = z.infer<typeof RestoreProjectOutputSchema>;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
API endpoint to delete a project, which sets the "delete" flag to `true` in the database.
3+
*/
4+
import isCollaborator from "@cocalc/server/projects/is-collaborator";
5+
import userIsInGroup from "@cocalc/server/accounts/is-in-group";
6+
import removeAllLicensesFromProject from "@cocalc/server/licenses/remove-all-from-project";
7+
import { getProject } from "@cocalc/server/projects/control";
8+
import userQuery from "@cocalc/database/user-query";
9+
import { isValidUUID } from "@cocalc/util/misc";
10+
11+
import getAccountId from "lib/account/get-account";
12+
import getParams from "lib/api/get-params";
13+
import { apiRoute, apiRouteOperation } from "lib/api";
14+
import { OkStatus } from "lib/api/status";
15+
import {
16+
DeleteProjectInputSchema,
17+
DeleteProjectOutputSchema,
18+
} from "lib/api/schema/projects/delete";
19+
20+
async function handle(req, res) {
21+
const { project_id } = getParams(req);
22+
const account_id = await getAccountId(req);
23+
24+
try {
25+
if (!isValidUUID(project_id)) {
26+
throw Error("project_id must be a valid uuid");
27+
}
28+
if (!account_id) {
29+
throw Error("must be signed in");
30+
}
31+
32+
// If client is not an administrator, they must be a project collaborator in order to
33+
// delete a project.
34+
if (
35+
!(await userIsInGroup(account_id, "admin")) &&
36+
!(await isCollaborator({ account_id, project_id }))
37+
) {
38+
throw Error("must be an owner to delete a project");
39+
}
40+
41+
// Remove all project licenses
42+
//
43+
await removeAllLicensesFromProject({ project_id });
44+
45+
// Stop project
46+
//
47+
const project = getProject(project_id);
48+
await project.stop();
49+
50+
// Set "deleted" flag. We do this last to ensure that the project is not consuming any
51+
// resources while it is in the deleted state.
52+
//
53+
await userQuery({
54+
account_id,
55+
query: {
56+
projects: {
57+
project_id,
58+
deleted: true,
59+
},
60+
},
61+
});
62+
63+
res.json(OkStatus);
64+
} catch (err) {
65+
res.json({ error: err.message });
66+
}
67+
}
68+
69+
export default apiRoute({
70+
deleteProject: apiRouteOperation({
71+
method: "POST",
72+
openApiOperation: {
73+
tags: ["Projects", "Admin"],
74+
},
75+
})
76+
.input({
77+
contentType: "application/json",
78+
body: DeleteProjectInputSchema,
79+
})
80+
.outputs([
81+
{
82+
status: 200,
83+
contentType: "application/json",
84+
body: DeleteProjectOutputSchema,
85+
},
86+
])
87+
.handler(handle),
88+
});

src/packages/next/pages/api/v2/projects/get.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async function handle(req, res) {
4040
}
4141

4242
export default apiRoute({
43-
get: apiRouteOperation({
43+
getProject: apiRouteOperation({
4444
method: "POST",
4545
openApiOperation: {
4646
tags: ["Projects", "Admin"],
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
API endpoint to restore a deleted a project, which sets the "delete" flag to `false` in
3+
the database.
4+
*/
5+
import { isValidUUID } from "@cocalc/util/misc";
6+
import isCollaborator from "@cocalc/server/projects/is-collaborator";
7+
import userIsInGroup from "@cocalc/server/accounts/is-in-group";
8+
import userQuery from "@cocalc/database/user-query";
9+
10+
import getAccountId from "lib/account/get-account";
11+
import getParams from "lib/api/get-params";
12+
import { apiRoute, apiRouteOperation } from "lib/api";
13+
import { OkStatus } from "lib/api/status";
14+
import {
15+
RestoreProjectInputSchema,
16+
RestoreProjectOutputSchema,
17+
} from "lib/api/schema/projects/restore";
18+
19+
async function handle(req, res) {
20+
const { project_id } = getParams(req);
21+
const account_id = await getAccountId(req);
22+
23+
try {
24+
if (!isValidUUID(project_id)) {
25+
throw Error("project_id must be a valid uuid");
26+
}
27+
if (!account_id) {
28+
throw Error("must be signed in");
29+
}
30+
31+
// If client is not an administrator, they must be a project collaborator in order to
32+
// restore a project.
33+
if (
34+
!(await userIsInGroup(account_id, "admin")) &&
35+
!(await isCollaborator({ account_id, project_id }))
36+
) {
37+
throw Error("must be an owner to restore a project");
38+
}
39+
40+
await userQuery({
41+
account_id,
42+
query: {
43+
projects: {
44+
project_id,
45+
deleted: false,
46+
},
47+
},
48+
});
49+
50+
res.json(OkStatus);
51+
} catch (err) {
52+
res.json({ error: err.message });
53+
}
54+
}
55+
56+
export default apiRoute({
57+
restoreProject: apiRouteOperation({
58+
method: "POST",
59+
openApiOperation: {
60+
tags: ["Projects", "Admin"],
61+
},
62+
})
63+
.input({
64+
contentType: "application/json",
65+
body: RestoreProjectInputSchema,
66+
})
67+
.outputs([
68+
{
69+
status: 200,
70+
contentType: "application/json",
71+
body: RestoreProjectOutputSchema,
72+
},
73+
])
74+
.handler(handle),
75+
});

src/packages/next/pages/api/v2/projects/start.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async function handle(req, res) {
3939
}
4040

4141
export default apiRoute({
42-
start: apiRouteOperation({
42+
startProject: apiRouteOperation({
4343
method: "POST",
4444
openApiOperation: {
4545
tags: ["Projects"],

src/packages/next/pages/api/v2/projects/stop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async function handle(req, res) {
3939
}
4040

4141
export default apiRoute({
42-
stop: apiRouteOperation({
42+
stopProject: apiRouteOperation({
4343
method: "POST",
4444
openApiOperation: {
4545
tags: ["Projects"],

src/packages/next/pages/api/v2/projects/update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ async function get(req) {
5656
}
5757

5858
export default apiRoute({
59-
update: apiRouteOperation({
59+
updateProject: apiRouteOperation({
6060
method: "POST",
6161
openApiOperation: {
6262
tags: ["Projects", "Admin"],
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import getPool, { PoolClient } from "@cocalc/database/pool";
2+
3+
interface Options {
4+
project_id: string;
5+
client?: PoolClient;
6+
}
7+
8+
// Set the site_license field of the entry in the PostgreSQL projects table with given
9+
// project_id to {}. The site_license field is JSONB.
10+
//
11+
export default async function removeAllLicensesFromProject({
12+
project_id,
13+
client,
14+
}: Options) {
15+
const pool = client ?? getPool();
16+
await pool.query(
17+
`
18+
UPDATE projects SET site_license = '{}'::JSONB WHERE project_id = $1
19+
`,
20+
[project_id],
21+
);
22+
}

0 commit comments

Comments
 (0)