Skip to content

Commit 9af6e17

Browse files
committed
feat: file soft deletion
1 parent fd3e886 commit 9af6e17

41 files changed

Lines changed: 1432 additions & 60 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

airborne-core-cli/action.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "fs";
22
import { promises as fsPromises } from "fs";
33
import path from "path";
4-
import { CreateApplicationCommand, CreateDimensionCommand, CreateFileCommand, CreateOrganisationCommand, CreatePackageCommand, CreateReleaseCommand, DeleteDimensionCommand, GetReleaseCommand, GetUserCommand, ListDimensionsCommand, ListFileGroupsCommand, ListFilesCommand, ListOrganisationsCommand, ListPackagesCommand, ListReleasesCommand, PostLoginCommand, RequestOrganisationCommand, ServeReleaseCommand, ServeReleaseV2Command, UpdateDimensionCommand, UpdateFileCommand, UploadFileCommand, AirborneClient } from "airborne-server-sdk"
4+
import { CreateApplicationCommand, CreateDimensionCommand, CreateFileCommand, CreateOrganisationCommand, CreatePackageCommand, CreateReleaseCommand, DeleteDimensionCommand, DeleteFileCommand, GetReleaseCommand, GetUserCommand, ListDimensionsCommand, ListFileGroupsCommand, ListFilesCommand, ListOrganisationsCommand, ListPackagesCommand, ListReleasesCommand, PostLoginCommand, RequestOrganisationCommand, ServeReleaseCommand, ServeReleaseV2Command, UpdateDimensionCommand, UpdateFileCommand, UploadFileCommand, AirborneClient } from "airborne-server-sdk"
55
import { fileURLToPath } from 'url';
66
import { dirname } from 'path';
77

@@ -302,6 +302,30 @@ export async function DeleteDimensionAction(paramsFile, options){
302302
return await client.send(command);
303303
}
304304

305+
export async function DeleteFileAction(paramsFile, options){
306+
let finalOptions = {};
307+
const requiredParams = ["file_id","organisation","application","token"];
308+
309+
if (paramsFile && paramsFile.startsWith('@')) {
310+
const jsonFilePath = paramsFile.slice(1);
311+
finalOptions = mergeOptionsWithJsonFile(options, jsonFilePath, requiredParams);
312+
} else if (paramsFile) {
313+
throw new Error("Params file must start with @ (e.g., @params.json)");
314+
} else {
315+
finalOptions = options;
316+
}
317+
318+
// Validate that all required options are present
319+
validateRequiredOptions(finalOptions, requiredParams);
320+
321+
322+
323+
324+
const client = await getClient(finalOptions.token, true);
325+
const command = new DeleteFileCommand(finalOptions);
326+
return await client.send(command);
327+
}
328+
305329
export async function GetReleaseAction(paramsFile, options){
306330
let finalOptions = {};
307331
const requiredParams = ["releaseId","organisation","application","token"];

airborne-core-cli/bin.js

100755100644
File mode changed.

airborne-core-cli/index.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
22
import path from "path";
3-
import { CreateApplicationAction, CreateDimensionAction, CreateFileAction, CreateOrganisationAction, CreatePackageAction, CreateReleaseAction, DeleteDimensionAction, GetReleaseAction, GetUserAction, ListDimensionsAction, ListFileGroupsAction, ListFilesAction, ListOrganisationsAction, ListPackagesAction, ListReleasesAction, PostLoginAction, RequestOrganisationAction, ServeReleaseAction, ServeReleaseV2Action, UpdateDimensionAction, UpdateFileAction, UploadFileAction } from "./action.js";
3+
import { CreateApplicationAction, CreateDimensionAction, CreateFileAction, CreateOrganisationAction, CreatePackageAction, CreateReleaseAction, DeleteDimensionAction, DeleteFileAction, GetReleaseAction, GetUserAction, ListDimensionsAction, ListFileGroupsAction, ListFilesAction, ListOrganisationsAction, ListPackagesAction, ListReleasesAction, PostLoginAction, RequestOrganisationAction, ServeReleaseAction, ServeReleaseV2Action, UpdateDimensionAction, UpdateFileAction, UploadFileAction } from "./action.js";
44
import { promises as fsPromises } from "fs";
55
import fs from "fs";
66
import { fileURLToPath } from 'url';
@@ -657,6 +657,83 @@ JSON file format (params.json):
657657
});
658658

659659

660+
program
661+
.command("DeleteFile")
662+
.argument('[params_file]', 'JSON file containing all parameters (use @params.json format)')
663+
.option("--file_id <file_id>", "file_id parameter")
664+
.option("--delete_all_versions <delete_all_versions>", "delete_all_versions parameter", (value) => {
665+
if (value.toLowerCase() === 'true') return true;
666+
if (value.toLowerCase() === 'false') return false;
667+
throw new Error("--delete_all_versions must be true or false");
668+
})
669+
.option("--organisation <organisation>", "organisation parameter")
670+
.option("--application <application>", "application parameter")
671+
.option("--token <token>", "Bearer token for authentication")
672+
.description(`
673+
Delete file request operation:
674+
675+
Usage 1 - Individual options:
676+
$ airborne-core-cli DeleteFile \\
677+
--file_id <file_id> \\
678+
--organisation <organisation> \\
679+
--application <application> \\
680+
--token <string> \\
681+
[--delete_all_versions <delete_all_versions>]
682+
683+
Usage 2 - JSON file:
684+
airborne-core-cli DeleteFile @file.json
685+
686+
Usage 3 - Mixed Usage:
687+
$ airborne-core-cli DeleteFile @params.json --file_id <value> --delete_all_versions <value> --token <value>
688+
689+
Parameters:
690+
--file_id <string> (required) : File key in the format "$file_path@version:$version_number" or "$file_path@tag:$tag"
691+
--delete_all_versions <boolean> (optional) : Whether to delete all versions of the file
692+
--organisation <string> (required) : Name of the organisation
693+
--application <string> (required) : Name of the application
694+
--token <string> (required) : Bearer token for authentication
695+
696+
`)
697+
.usage('<action> [options]')
698+
.addHelpText('after', `
699+
Examples:
700+
701+
1. Using individual options:
702+
$ airborne-core-cli DeleteFile \\
703+
--file_id <file_id> \\
704+
--organisation <organisation> \\
705+
--application <application> \\
706+
--token <string> \\
707+
[--delete_all_versions <delete_all_versions>]
708+
709+
2. Using JSON file:
710+
$ airborne-core-cli DeleteFile @params.json
711+
712+
3. Mixed approach (JSON file + CLI overrides):
713+
$ airborne-core-cli DeleteFile @params.json --file_id <value> --delete_all_versions <value> --token <value>
714+
715+
JSON file format (params.json):
716+
{
717+
"file_id": "example_file_id",
718+
"delete_all_versions": "example_delete_all_versions",
719+
"organisation": "example_organisation",
720+
"application": "example_application",
721+
"token": "your_bearer_token_here"
722+
}`)
723+
.action(async (paramsFile, options) => {
724+
try {
725+
726+
const output = await DeleteFileAction(paramsFile, options);
727+
console.log(printColoredJSON(output));
728+
process.exit(0);
729+
} catch (err) {
730+
console.error("Error message:", err.message);
731+
console.error("Error executing:", printColoredJSON(err));
732+
process.exit(1);
733+
}
734+
});
735+
736+
660737
program
661738
.command("GetRelease")
662739
.argument('[params_file]', 'JSON file containing all parameters (use @params.json format)')

airborne_dashboard/app/dashboard/[orgId]/[appId]/files/page.tsx

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,7 @@ import {
1616
DialogTitle,
1717
} from "@/components/ui/dialog";
1818
import { Label } from "@/components/ui/label";
19-
import { Search, ChevronDown, ChevronRight, File, Filter, Plus, Loader2, Pencil } from "lucide-react";
20-
import { FileCreationModal } from "@/components/file-creation-modal";
21-
import { useAppContext } from "@/providers/app-context";
22-
import { apiFetch } from "@/lib/api";
23-
import { hasAppAccess } from "@/lib/utils";
24-
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
19+
import { Search, ChevronDown, ChevronRight, File, Filter, Plus, Loader2, Pencil, Trash2 } from "lucide-react";
2520
import { toastSuccess, toastError } from "@/hooks/use-toast";
2621
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2722
import {
@@ -34,8 +29,19 @@ import {
3429
PaginationEllipsis,
3530
} from "@/components/ui/pagination";
3631
import type { FileGroup, FileGroupsResponse, TagInfo, TagsResponse } from "@/types/files";
32+
import { FileCreationModal } from "@/components/file-creation-modal";
33+
import { FileDeleteModal } from "@/components/file-delete-modal";
34+
import { useAppContext } from "@/providers/app-context";
35+
import { apiFetch } from "@/lib/api";
36+
import { hasAppAccess } from "@/lib/utils";
37+
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
3738

3839
const FILES_PER_PAGE = 15;
40+
type FileToDelete = {
41+
id: string;
42+
file_path: string;
43+
version: number;
44+
};
3945

4046
export default function FilesPage() {
4147
const { token, org, app, getOrgAccess, getAppAccess } = useAppContext();
@@ -51,6 +57,8 @@ export default function FilesPage() {
5157
// UI state
5258
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
5359
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
60+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
61+
const [fileToDelete, setFileToDelete] = useState<FileToDelete | null>(null);
5462

5563
// Edit Tag dialog state
5664
const [isEditTagDialogOpen, setIsEditTagDialogOpen] = useState(false);
@@ -95,6 +103,15 @@ export default function FilesPage() {
95103
const totalPages = groupsData?.total_pages || 1;
96104
const tags = tagsData?.data || [];
97105

106+
function handleDeleteClick(filePath: string, version: number) {
107+
setFileToDelete({
108+
id: `${filePath}@version:${version}`,
109+
file_path: filePath,
110+
version,
111+
});
112+
setIsDeleteModalOpen(true);
113+
}
114+
98115
const handleSearchChange = (value: string) => {
99116
setSearchQuery(value);
100117
setPage(1);
@@ -395,7 +412,7 @@ export default function FilesPage() {
395412
<TableHead>URL</TableHead>
396413
<TableHead>Size</TableHead>
397414
<TableHead>Created</TableHead>
398-
<TableHead className="w-24">Actions</TableHead>
415+
<TableHead className="w-52">Actions</TableHead>
399416
</TableRow>
400417
</TableHeader>
401418
<TableBody>
@@ -424,18 +441,32 @@ export default function FilesPage() {
424441
</TableCell>
425442
<TableCell>
426443
{hasAppAccess(getOrgAccess(org), getAppAccess(org, app)) && (
427-
<Button
428-
variant="ghost"
429-
size="sm"
430-
className="h-7 px-2"
431-
onClick={(e) => {
432-
e.stopPropagation();
433-
handleEditTag(group.file_path, version.version, versionTag || "");
434-
}}
435-
>
436-
<Pencil className="h-3.5 w-3.5 mr-1" />
437-
Edit Tag
438-
</Button>
444+
<div className="flex items-center gap-1">
445+
<Button
446+
variant="ghost"
447+
size="sm"
448+
className="h-7 px-2"
449+
onClick={(e) => {
450+
e.stopPropagation();
451+
handleEditTag(group.file_path, version.version, versionTag || "");
452+
}}
453+
>
454+
<Pencil className="h-3.5 w-3.5 mr-1" />
455+
Edit Tag
456+
</Button>
457+
<Button
458+
variant="ghost"
459+
size="sm"
460+
className="h-7 px-2 text-destructive hover:text-destructive"
461+
onClick={(e) => {
462+
e.stopPropagation();
463+
handleDeleteClick(group.file_path, version.version);
464+
}}
465+
>
466+
<Trash2 className="h-3.5 w-3.5 mr-1" />
467+
Delete
468+
</Button>
469+
</div>
439470
)}
440471
</TableCell>
441472
</TableRow>
@@ -521,6 +552,17 @@ export default function FilesPage() {
521552
</DialogFooter>
522553
</DialogContent>
523554
</Dialog>
555+
<FileDeleteModal
556+
open={isDeleteModalOpen}
557+
onOpenChange={(open) => {
558+
setIsDeleteModalOpen(open);
559+
if (!open) {
560+
setFileToDelete(null);
561+
}
562+
}}
563+
file={fileToDelete}
564+
onDeleted={() => mutate()}
565+
/>
524566
</div>
525567
);
526568
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import { Button } from "@/components/ui/button";
13+
import { Checkbox } from "@/components/ui/checkbox";
14+
import { Label } from "@/components/ui/label";
15+
import { useAppContext } from "@/providers/app-context";
16+
import { apiFetch } from "@/lib/api";
17+
import { Trash2 } from "lucide-react";
18+
19+
interface FileDeleteModalProps {
20+
open: boolean;
21+
onOpenChange: (open: boolean) => void;
22+
file: {
23+
id: string;
24+
file_path: string;
25+
version: number;
26+
} | null;
27+
onDeleted?: () => void;
28+
}
29+
30+
export function FileDeleteModal({ open, onOpenChange, file, onDeleted }: FileDeleteModalProps) {
31+
const { token, org, app } = useAppContext();
32+
const [deleteAllVersions, setDeleteAllVersions] = useState(false);
33+
const [isDeleting, setIsDeleting] = useState(false);
34+
const [error, setError] = useState<string | null>(null);
35+
36+
const handleDelete = async () => {
37+
if (!file) return;
38+
39+
setIsDeleting(true);
40+
setError(null);
41+
42+
try {
43+
await apiFetch(
44+
"/file",
45+
{
46+
method: "DELETE",
47+
query: {
48+
file_id: file.id,
49+
delete_all_versions: deleteAllVersions,
50+
},
51+
},
52+
{ token, org, app }
53+
);
54+
onOpenChange(false);
55+
setDeleteAllVersions(false);
56+
onDeleted?.();
57+
} catch (error: unknown) {
58+
setError(error instanceof Error ? error.message : "Failed to delete file");
59+
} finally {
60+
setIsDeleting(false);
61+
}
62+
};
63+
64+
const handleClose = () => {
65+
if (!isDeleting) {
66+
onOpenChange(false);
67+
setDeleteAllVersions(false);
68+
setError(null);
69+
}
70+
};
71+
72+
if (!file) return null;
73+
74+
return (
75+
<Dialog open={open} onOpenChange={handleClose}>
76+
<DialogContent className="sm:max-w-md">
77+
<DialogHeader>
78+
<DialogTitle className="flex items-center gap-2 font-[family-name:var(--font-space-grotesk)]">
79+
<Trash2 className="h-5 w-5 text-red-500" />
80+
Delete File
81+
</DialogTitle>
82+
<DialogDescription>
83+
Are you sure you want to delete{" "}
84+
<code className="rounded bg-muted px-1 py-0.5 text-sm font-mono">{file.file_path}</code>?
85+
<br />
86+
<span className="text-red-600">This action cannot be undone.</span>
87+
</DialogDescription>
88+
</DialogHeader>
89+
90+
<div className="space-y-4 py-4">
91+
<div className="flex items-start space-x-3 rounded-lg border p-4">
92+
<Checkbox
93+
id="deleteAllVersions"
94+
checked={deleteAllVersions}
95+
onCheckedChange={(checked) => setDeleteAllVersions(checked === true)}
96+
className="mt-0.5"
97+
/>
98+
<div className="space-y-1">
99+
<Label htmlFor="deleteAllVersions" className="text-sm font-medium leading-none cursor-pointer">
100+
Delete all versions
101+
</Label>
102+
<p className="text-xs text-muted-foreground">
103+
When checked, all versions of this file will be deleted. Otherwise, only version {file.version} will be
104+
removed.
105+
</p>
106+
</div>
107+
</div>
108+
109+
{error && <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50">{error}</div>}
110+
</div>
111+
112+
<DialogFooter>
113+
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
114+
Cancel
115+
</Button>
116+
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting} className="gap-2">
117+
{isDeleting ? "Deleting..." : "Delete File"}
118+
</Button>
119+
</DialogFooter>
120+
</DialogContent>
121+
</Dialog>
122+
);
123+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Remove status column and enum type from files table
2+
ALTER TABLE hyperotaserver.files DROP COLUMN status;
3+
DROP TYPE IF EXISTS hyperotaserver.file_status;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TYPE hyperotaserver.file_status AS ENUM ('pending', 'ready', 'deleted');
2+
3+
ALTER TABLE hyperotaserver.files
4+
ADD COLUMN status hyperotaserver.file_status NOT NULL DEFAULT 'pending';
5+
6+
-- Backfill existing rows: files with size > 0 are ready, rest are pending
7+
UPDATE hyperotaserver.files SET status = 'ready' WHERE size > 0;
8+
UPDATE hyperotaserver.files SET status = 'pending' WHERE size = 0;

0 commit comments

Comments
 (0)