robodev 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/collect-files.test.ts +19 -0
- package/src/collect-files.ts +1 -0
- package/src/config.test.ts +26 -0
- package/src/config.ts +34 -5
- package/src/emit-starter.test.ts +54 -0
- package/src/emit-starter.ts +47 -13
- package/src/index.ts +37 -4
- package/templates/auth-chat/package.json +1 -1
- package/templates/auth-chat/src/main.tsx +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/catalog.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/space/.config/local.spa.template.yml +27 -0
- package/templates/space/.oxlint-base.json +29 -0
- package/templates/space/.oxlintrc.json +78 -0
- package/templates/space/.rulesync/rules/frontend-api-boundary.md +36 -0
- package/templates/space/.rulesync/rules/frontend-component-structure.md +38 -0
- package/templates/space/.rulesync/rules/frontend-forms.md +32 -0
- package/templates/space/.rulesync/rules/frontend-notifications.md +24 -0
- package/templates/space/.rulesync/rules/frontend-povio-components.md +42 -0
- package/templates/space/.rulesync/rules/frontend-povio-ui.md +45 -0
- package/templates/space/.rulesync/rules/frontend-query-autocomplete.md +29 -0
- package/templates/space/.rulesync/rules/frontend-tables-and-lists.md +34 -0
- package/templates/space/.rulesync/rules/frontend-translations.md +26 -0
- package/templates/space/.rulesync/rules/project-overview.md +32 -0
- package/templates/space/.rulesync/rules/robodev-api.md +30 -0
- package/templates/space/.rulesync/rules/robodev-auth.md +40 -0
- package/templates/space/.rulesync/rules/robodev-database.md +22 -0
- package/templates/space/.rulesync/rules/role-based-app-structure.md +36 -0
- package/templates/space/.rulesync/skills/media-feature/SKILL.md +21 -0
- package/templates/space/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
- package/templates/space/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
- package/templates/space/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
- package/templates/space/.rulesync/skills/robodev-table/SKILL.md +12 -0
- package/templates/space/README.md +26 -7
- package/templates/space/api/_lib.ts +359 -0
- package/templates/space/api/aliens/labels.ts +19 -0
- package/templates/space/api/files/presigned-url.ts +48 -0
- package/templates/space/api/files/upload.ts +29 -0
- package/templates/space/api/planets/[id]/like.ts +36 -0
- package/templates/space/api/planets/[id].ts +56 -13
- package/templates/space/api/planets/paginate.ts +36 -0
- package/templates/space/api/planets.ts +52 -44
- package/templates/space/apps/fe/index.html +24 -0
- package/templates/space/apps/fe/openapi-codegen.config.ts +37 -0
- package/templates/space/apps/fe/package.json +82 -0
- package/templates/space/apps/fe/public/apple-touch-icon.png +0 -0
- package/templates/space/apps/fe/public/favicon-96x96.png +0 -0
- package/templates/space/apps/fe/public/favicon.ico +0 -0
- package/templates/space/apps/fe/public/favicon.svg +3 -0
- package/templates/space/apps/fe/public/site.webmanifest +21 -0
- package/templates/space/apps/fe/public/web-app-manifest-192x192.png +0 -0
- package/templates/space/apps/fe/public/web-app-manifest-512x512.png +0 -0
- package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
- package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
- package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
- package/templates/space/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
- package/templates/space/apps/fe/src/assets/locales/en/translation.json +301 -0
- package/templates/space/apps/fe/src/assets/locales/sl/translation.json +301 -0
- package/templates/space/apps/fe/src/clients/app-rest-client.ts +15 -0
- package/templates/space/apps/fe/src/clients/auth-token-store.ts +101 -0
- package/templates/space/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
- package/templates/space/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
- package/templates/space/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
- package/templates/space/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
- package/templates/space/apps/fe/src/components/404.tsx +18 -0
- package/templates/space/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
- package/templates/space/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
- package/templates/space/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
- package/templates/space/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
- package/templates/space/apps/fe/src/components/features/home/HomePage.tsx +21 -0
- package/templates/space/apps/fe/src/components/features/planets/details/PlanetDetailsPage.tsx +139 -0
- package/templates/space/apps/fe/src/components/features/planets/details/PlanetEditPage.tsx +266 -0
- package/templates/space/apps/fe/src/components/features/planets/feed/PlanetFeedCard.tsx +131 -0
- package/templates/space/apps/fe/src/components/features/planets/feed/PlanetsFeedPage.tsx +119 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetCard.tsx +89 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetCreateModal.tsx +179 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.css +3 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsFilters.tsx +73 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsGridPage.tsx +51 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsInfiniteTablePage.tsx +79 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsPage.tsx +65 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTablePage.tsx +72 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsTableSummary.tsx +29 -0
- package/templates/space/apps/fe/src/components/features/planets/list/PlanetsViewTabs.tsx +79 -0
- package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTable.tsx +30 -0
- package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableActions.tsx +64 -0
- package/templates/space/apps/fe/src/components/features/planets/list/table/PlanetsTableInfinite.tsx +36 -0
- package/templates/space/apps/fe/src/components/features/planets/list/table/planetsTableColumns.tsx +72 -0
- package/templates/space/apps/fe/src/components/features/planets/list/table/usePlanetTableFilters.tsx +102 -0
- package/templates/space/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
- package/templates/space/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
- package/templates/space/apps/fe/src/components/layout/AppLayout.tsx +19 -0
- package/templates/space/apps/fe/src/components/layout/app-header/AppHeader.tsx +93 -0
- package/templates/space/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +53 -0
- package/templates/space/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
- package/templates/space/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
- package/templates/space/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
- package/templates/space/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
- package/templates/space/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
- package/templates/space/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
- package/templates/space/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
- package/templates/space/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
- package/templates/space/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
- package/templates/space/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
- package/templates/space/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
- package/templates/space/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
- package/templates/space/apps/fe/src/components/shared/ui/Card.tsx +60 -0
- package/templates/space/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
- package/templates/space/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
- package/templates/space/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
- package/templates/space/apps/fe/src/config/app.config.ts +35 -0
- package/templates/space/apps/fe/src/config/i18n.ts +43 -0
- package/templates/space/apps/fe/src/config/inits/a11y.ts +14 -0
- package/templates/space/apps/fe/src/config/inits/logger.ts +7 -0
- package/templates/space/apps/fe/src/config/inits/sentry.ts +21 -0
- package/templates/space/apps/fe/src/config/jwt.config.ts +2 -0
- package/templates/space/apps/fe/src/config/query.config.ts +20 -0
- package/templates/space/apps/fe/src/hooks/useAuth.ts +5 -0
- package/templates/space/apps/fe/src/main.tsx +52 -0
- package/templates/space/apps/fe/src/pages/(guest)/login.tsx +23 -0
- package/templates/space/apps/fe/src/pages/(guest)/register.tsx +23 -0
- package/templates/space/apps/fe/src/pages/(guest)/route.tsx +18 -0
- package/templates/space/apps/fe/src/pages/(private)/index.tsx +23 -0
- package/templates/space/apps/fe/src/pages/(private)/planets/$id/edit.tsx +53 -0
- package/templates/space/apps/fe/src/pages/(private)/planets/$id/index.tsx +41 -0
- package/templates/space/apps/fe/src/pages/(private)/planets/index.tsx +24 -0
- package/templates/space/apps/fe/src/pages/(private)/planets-feed.tsx +23 -0
- package/templates/space/apps/fe/src/pages/(private)/profile.tsx +23 -0
- package/templates/space/apps/fe/src/pages/(private)/route.tsx +18 -0
- package/templates/space/apps/fe/src/pages/(public)/auth.tsx +37 -0
- package/templates/space/apps/fe/src/pages/(public)/route.tsx +9 -0
- package/templates/space/apps/fe/src/pages/__root.tsx +164 -0
- package/templates/space/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
- package/templates/space/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
- package/templates/space/apps/fe/src/providers/index.tsx +44 -0
- package/templates/space/apps/fe/src/providers/jwt.provider.tsx +95 -0
- package/templates/space/apps/fe/src/routeTree.gen.ts +309 -0
- package/templates/space/apps/fe/src/styles/base.css +103 -0
- package/templates/space/apps/fe/src/styles/fonts/fonts.tsx +10 -0
- package/templates/space/apps/fe/src/styles/fonts/general-sans.css +31 -0
- package/templates/space/apps/fe/src/styles/globals.css +28 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
- package/templates/space/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
- package/templates/space/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
- package/templates/space/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
- package/templates/space/apps/fe/src/styles/theme.css +2177 -0
- package/templates/space/apps/fe/src/types/i18next.d.ts +12 -0
- package/templates/space/apps/fe/src/types/table.d.ts +17 -0
- package/templates/space/apps/fe/src/types/ui.d.ts +26 -0
- package/templates/space/apps/fe/src/types/vite-env.d.ts +20 -0
- package/templates/space/apps/fe/src/utils/date.utils.ts +17 -0
- package/templates/space/apps/fe/src/utils/image-fallback.ts +9 -0
- package/templates/space/apps/fe/src/utils/media-upload.ts +63 -0
- package/templates/space/apps/fe/src/utils/number.utils.ts +12 -0
- package/templates/space/apps/fe/src/utils/string.utils.ts +5 -0
- package/templates/space/apps/fe/src/vite-env.d.ts +1 -0
- package/templates/space/apps/fe/tsconfig.app.json +32 -0
- package/templates/space/apps/fe/tsconfig.json +9 -0
- package/templates/space/apps/fe/tsconfig.node.json +31 -0
- package/templates/space/apps/fe/vite.config.ts +94 -0
- package/templates/space/database.ts +33 -13
- package/templates/space/openapi.json +1819 -0
- package/templates/space/oxfmt.config.js +23 -0
- package/templates/space/package.json +6 -13
- package/templates/space/rulesync.jsonc +18 -0
- package/templates/space/tsconfig.json +2 -4
- package/templates/space/api/health.ts +0 -7
- package/templates/space/api/launch.ts +0 -29
- package/templates/space/api/me.ts +0 -14
- package/templates/space/api/motto.ts +0 -9
- package/templates/space/api/rockets.ts +0 -43
- package/templates/space/index.html +0 -138
- package/templates/space/src/main.tsx +0 -229
- package/templates/space/vite.config.ts +0 -7
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
# {{NAME}}
|
|
2
2
|
|
|
3
|
-
Starter
|
|
3
|
+
Starter from `robodev create`: Tiny Template schema (`planets`, `aliens`, `media`, `planet_likes`), file-based APIs in `api/`, and a **local** Povio UI in `apps/fe`.
|
|
4
4
|
|
|
5
|
-
The project host is
|
|
5
|
+
The project host is **API-only**. After create or deploy, the printed App URL serves APIs and Swagger — not the SPA. Run the UI with bun:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
cd apps/fe
|
|
9
|
+
bun install
|
|
10
|
+
bun openapi:gen
|
|
11
|
+
bun dev
|
|
9
12
|
```
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
Vite defaults to port **3000** (`VITE_DEV_PORT`). `bun` is required for `apps/fe`.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Auth, link, and deploy from this **root**:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx robodev auth
|
|
20
|
+
npx robodev link
|
|
21
|
+
npx robodev deploy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, and rewrite `.config/local.spa.yml` so the FE talks to the project host.
|
|
25
|
+
|
|
26
|
+
Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`.
|
|
14
27
|
|
|
15
|
-
|
|
28
|
+
Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
Regenerate AI rules after editing `.rulesync/`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun rules:gen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Docs: https://robodev.povio.dev/docs
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { asc, eq, type InferSelectModel } from "@robodev-ai/sdk";
|
|
2
|
+
import type { ApiHandlerContext } from "@robodev-ai/sdk";
|
|
3
|
+
import { aliens, media, planetLikes, planets } from "../database";
|
|
4
|
+
|
|
5
|
+
export const PLANET_IMAGE_RESOURCE = "planet-image";
|
|
6
|
+
const DEFAULT_ORDER = "+name";
|
|
7
|
+
const SORTABLE = new Set(["id", "name", "discoveryDate", "createdAt", "updatedAt"]);
|
|
8
|
+
|
|
9
|
+
export const ALIEN_NAMES = [
|
|
10
|
+
"Andromedan",
|
|
11
|
+
"Arcturian",
|
|
12
|
+
"Betelgeusian",
|
|
13
|
+
"Centaurian",
|
|
14
|
+
"Denebian",
|
|
15
|
+
"Eridanian",
|
|
16
|
+
"Fomalhautian",
|
|
17
|
+
"Ganymedean",
|
|
18
|
+
"Hydran",
|
|
19
|
+
"Lyran",
|
|
20
|
+
"Orionid",
|
|
21
|
+
"Vegan",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export const PLANET_SEEDS = [
|
|
25
|
+
["Mercury", "Small rocky world closest to the system star"],
|
|
26
|
+
["Venus", "Dense atmosphere with extreme surface temperatures"],
|
|
27
|
+
["Earth", "Temperate ocean world with abundant life"],
|
|
28
|
+
["Mars", "Cold desert world with polar ice caps"],
|
|
29
|
+
["Jupiter", "Massive gas giant with a long-lived storm system"],
|
|
30
|
+
["Saturn", "Ringed gas giant with many icy moons"],
|
|
31
|
+
["Uranus", "Ice giant with a tilted rotation axis"],
|
|
32
|
+
["Neptune", "Distant ice giant with high-speed winds"],
|
|
33
|
+
["Kepler-22b", "Exoplanet candidate in a habitable-zone orbit"],
|
|
34
|
+
["Proxima Centauri b", "Nearby terrestrial exoplanet orbiting a red dwarf"],
|
|
35
|
+
["TRAPPIST-1e", "Compact-system rocky planet in a temperate orbit"],
|
|
36
|
+
["HD 209458 b", "Hot gas giant with an observed atmosphere"],
|
|
37
|
+
["Gliese 581g", "Candidate super-Earth in a nearby stellar system"],
|
|
38
|
+
["LHS 1140 b", "Dense rocky super-Earth in a habitable-zone orbit"],
|
|
39
|
+
["TOI-700 d", "Earth-size planet orbiting a small cool star"],
|
|
40
|
+
["K2-18 b", "Sub-Neptune with detected atmospheric molecules"],
|
|
41
|
+
["WASP-12b", "Ultra-hot gas giant closely orbiting its star"],
|
|
42
|
+
["55 Cancri e", "Super-Earth with a very short orbital period"],
|
|
43
|
+
["Kepler-452b", "Large terrestrial candidate orbiting a Sun-like star"],
|
|
44
|
+
["GJ 1214 b", "Sub-Neptune with a hazy atmosphere"],
|
|
45
|
+
["Ross 128 b", "Nearby temperate exoplanet around a quiet red dwarf"],
|
|
46
|
+
["Tau Ceti e", "Candidate planet in a nearby Sun-like system"],
|
|
47
|
+
["Wolf 1061 c", "Rocky candidate near the inner habitable zone"],
|
|
48
|
+
["Teegarden's Star b", "Temperate Earth-mass candidate around a red dwarf"],
|
|
49
|
+
["Kapteyn b", "Candidate super-Earth around an old nearby star"],
|
|
50
|
+
["Kepler-186f", "Earth-size planet in a cool star's habitable zone"],
|
|
51
|
+
["OGLE-2005-BLG-390Lb", "Cold distant world found by microlensing"],
|
|
52
|
+
["HAT-P-7b", "Hot Jupiter with unusual atmospheric circulation"],
|
|
53
|
+
["WASP-19b", "Hot Jupiter with a compact orbit"],
|
|
54
|
+
["CoRoT-7b", "Rocky exoplanet with extreme dayside heating"],
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export type PlanetRow = InferSelectModel<typeof planets>;
|
|
58
|
+
export type AlienRow = InferSelectModel<typeof aliens>;
|
|
59
|
+
export type MediaRow = InferSelectModel<typeof media>;
|
|
60
|
+
|
|
61
|
+
export type PlanetFilter = {
|
|
62
|
+
search?: string | null;
|
|
63
|
+
alienId?: string | null;
|
|
64
|
+
discoveryDate?: { start?: string | null; end?: string | null } | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PlanetDto = {
|
|
68
|
+
id: string;
|
|
69
|
+
userId: string;
|
|
70
|
+
alienId?: string | null;
|
|
71
|
+
discoveryDate?: string | null;
|
|
72
|
+
name: string;
|
|
73
|
+
description?: string | null;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
image?: { id: string; url: string } | null;
|
|
77
|
+
creatorName?: string;
|
|
78
|
+
alienName?: string;
|
|
79
|
+
likesCount: number;
|
|
80
|
+
likedByMe: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function fail(status: number, message: string) {
|
|
84
|
+
return { status, body: { message } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function iso(value: Date | string | null | undefined): string | null {
|
|
88
|
+
if (!value) return null;
|
|
89
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function requiredIso(value: Date | string | null | undefined): string {
|
|
93
|
+
return iso(value) ?? new Date().toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseFilter(query: Record<string, unknown>): PlanetFilter {
|
|
97
|
+
const raw = query.filter;
|
|
98
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
99
|
+
try {
|
|
100
|
+
return parseFilter(JSON.parse(raw) as Record<string, unknown>);
|
|
101
|
+
} catch {
|
|
102
|
+
return { search: raw };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (raw && typeof raw === "object") {
|
|
106
|
+
const filter = raw as Record<string, unknown>;
|
|
107
|
+
const discovery = filter.discoveryDate;
|
|
108
|
+
return {
|
|
109
|
+
search: typeof filter.search === "string" ? filter.search : undefined,
|
|
110
|
+
alienId: typeof filter.alienId === "string" ? filter.alienId : null,
|
|
111
|
+
discoveryDate:
|
|
112
|
+
discovery && typeof discovery === "object"
|
|
113
|
+
? (discovery as PlanetFilter["discoveryDate"])
|
|
114
|
+
: undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const start = query["filter[discoveryDate][start]"] ?? query.discoveryDateStart;
|
|
118
|
+
const end = query["filter[discoveryDate][end]"] ?? query.discoveryDateEnd;
|
|
119
|
+
return {
|
|
120
|
+
search:
|
|
121
|
+
typeof query["filter[search]"] === "string"
|
|
122
|
+
? (query["filter[search]"] as string)
|
|
123
|
+
: typeof query.search === "string"
|
|
124
|
+
? query.search
|
|
125
|
+
: undefined,
|
|
126
|
+
alienId:
|
|
127
|
+
typeof query["filter[alienId]"] === "string"
|
|
128
|
+
? (query["filter[alienId]"] as string)
|
|
129
|
+
: typeof query.alienId === "string"
|
|
130
|
+
? query.alienId
|
|
131
|
+
: undefined,
|
|
132
|
+
discoveryDate:
|
|
133
|
+
typeof start === "string" || typeof end === "string"
|
|
134
|
+
? {
|
|
135
|
+
start: typeof start === "string" ? start : null,
|
|
136
|
+
end: typeof end === "string" ? end : null,
|
|
137
|
+
}
|
|
138
|
+
: undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseOrder(order: string | null | undefined) {
|
|
143
|
+
return (order || DEFAULT_ORDER)
|
|
144
|
+
.split(",")
|
|
145
|
+
.map((item) => item.trim())
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.map((item) => {
|
|
148
|
+
const direction = item.startsWith("-") ? "desc" : "asc";
|
|
149
|
+
const key = item.replace(/^[+-]/, "");
|
|
150
|
+
return SORTABLE.has(key) ? { key, direction } : undefined;
|
|
151
|
+
})
|
|
152
|
+
.filter((item): item is { key: string; direction: "asc" | "desc" } => Boolean(item));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compare(a: PlanetRow, b: PlanetRow, key: string, direction: "asc" | "desc") {
|
|
156
|
+
const aValue = a[key as keyof PlanetRow];
|
|
157
|
+
const bValue = b[key as keyof PlanetRow];
|
|
158
|
+
if (!aValue && !bValue) return 0;
|
|
159
|
+
if (!aValue) return 1;
|
|
160
|
+
if (!bValue) return -1;
|
|
161
|
+
const left = aValue instanceof Date ? aValue.toISOString() : String(aValue);
|
|
162
|
+
const right = bValue instanceof Date ? bValue.toISOString() : String(bValue);
|
|
163
|
+
const result = left.localeCompare(right);
|
|
164
|
+
return direction === "asc" ? result : -result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function sortPlanets(rows: PlanetRow[], order: string | null | undefined) {
|
|
168
|
+
const sorting = parseOrder(order);
|
|
169
|
+
return [...rows].sort((a, b) => {
|
|
170
|
+
for (const sort of sorting) {
|
|
171
|
+
const result = compare(a, b, sort.key, sort.direction);
|
|
172
|
+
if (result !== 0) return result;
|
|
173
|
+
}
|
|
174
|
+
return a.name.localeCompare(b.name);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function filterPlanetRows(
|
|
179
|
+
rows: PlanetRow[],
|
|
180
|
+
alienById: Map<string, AlienRow>,
|
|
181
|
+
filters: PlanetFilter | undefined,
|
|
182
|
+
) {
|
|
183
|
+
if (!filters) return rows;
|
|
184
|
+
const search = filters.search?.trim().toLocaleLowerCase();
|
|
185
|
+
return rows.filter((planet) => {
|
|
186
|
+
if (search) {
|
|
187
|
+
const haystack = [
|
|
188
|
+
planet.name,
|
|
189
|
+
planet.description,
|
|
190
|
+
planet.creatorName,
|
|
191
|
+
planet.alienId ? alienById.get(planet.alienId)?.name : undefined,
|
|
192
|
+
];
|
|
193
|
+
if (!haystack.some((value) => value?.toLocaleLowerCase().includes(search))) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (filters.alienId && planet.alienId !== filters.alienId) return false;
|
|
198
|
+
const range = filters.discoveryDate;
|
|
199
|
+
if (range?.start || range?.end) {
|
|
200
|
+
if (!planet.discoveryDate) return false;
|
|
201
|
+
const timestamp = Date.parse(requiredIso(planet.discoveryDate));
|
|
202
|
+
if (range.start && timestamp < Date.parse(range.start)) return false;
|
|
203
|
+
if (range.end && timestamp > Date.parse(range.end)) return false;
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function paginate<T>(items: T[], page?: number, limit?: number) {
|
|
210
|
+
const currentPage = page && page > 0 ? page : 1;
|
|
211
|
+
const currentLimit = limit && limit > 0 ? Math.min(limit, 20) : 20;
|
|
212
|
+
const start = (currentPage - 1) * currentLimit;
|
|
213
|
+
const nextPage = currentPage * currentLimit < items.length ? currentPage + 1 : null;
|
|
214
|
+
return {
|
|
215
|
+
items: items.slice(start, start + currentLimit),
|
|
216
|
+
page: currentPage,
|
|
217
|
+
cursor: String(currentPage),
|
|
218
|
+
nextCursor: nextPage ? String(nextPage) : null,
|
|
219
|
+
limit: currentLimit,
|
|
220
|
+
totalItems: items.length,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function ensureSeed(db: ApiHandlerContext<unknown, unknown>["db"], userId: string) {
|
|
225
|
+
const existingAliens = await db.select({ id: aliens.id }).from(aliens).limit(1);
|
|
226
|
+
if (existingAliens.length === 0) {
|
|
227
|
+
const now = new Date();
|
|
228
|
+
try {
|
|
229
|
+
await db.insert(aliens).values(
|
|
230
|
+
ALIEN_NAMES.map((name) => ({
|
|
231
|
+
name,
|
|
232
|
+
createdAt: now,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
})),
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
// First request won the seed race.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const existingPlanets = await db.select({ id: planets.id }).from(planets).limit(1);
|
|
242
|
+
if (existingPlanets.length > 0) return;
|
|
243
|
+
|
|
244
|
+
const alienRows = await db.select().from(aliens).orderBy(asc(aliens.name));
|
|
245
|
+
if (alienRows.length === 0) return;
|
|
246
|
+
const now = new Date();
|
|
247
|
+
try {
|
|
248
|
+
await db.insert(planets).values(
|
|
249
|
+
PLANET_SEEDS.map(([name, description], index) => ({
|
|
250
|
+
userId,
|
|
251
|
+
creatorName: "Demo User",
|
|
252
|
+
alienId: alienRows[index % alienRows.length]?.id ?? null,
|
|
253
|
+
discoveryDate:
|
|
254
|
+
index % 2 === 0
|
|
255
|
+
? new Date(`20${String(index + 1).padStart(2, "0")}-01-01T00:00:00.000Z`)
|
|
256
|
+
: null,
|
|
257
|
+
name,
|
|
258
|
+
description,
|
|
259
|
+
createdAt: now,
|
|
260
|
+
updatedAt: now,
|
|
261
|
+
})),
|
|
262
|
+
);
|
|
263
|
+
} catch {
|
|
264
|
+
// First request won the seed race.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function resolveImage(
|
|
269
|
+
ctx: ApiHandlerContext<unknown, unknown>,
|
|
270
|
+
imageId: string | null | undefined,
|
|
271
|
+
): Promise<{ id: string; url: string } | null> {
|
|
272
|
+
if (!imageId) return null;
|
|
273
|
+
const [row] = await ctx.db.select().from(media).where(eq(media.id, imageId)).limit(1);
|
|
274
|
+
if (!row?.uploaded) return null;
|
|
275
|
+
try {
|
|
276
|
+
const url = await ctx.storage.getUrl(row.key);
|
|
277
|
+
return { id: row.id, url };
|
|
278
|
+
} catch {
|
|
279
|
+
return { id: row.id, url: `/storage/objects/${encodeURIComponent(row.key)}` };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function enrichPlanet(
|
|
284
|
+
ctx: ApiHandlerContext<unknown, unknown>,
|
|
285
|
+
planet: PlanetRow,
|
|
286
|
+
currentUserId: string,
|
|
287
|
+
alienById: Map<string, AlienRow>,
|
|
288
|
+
likes: { planetId: string; userId: string }[],
|
|
289
|
+
): Promise<PlanetDto> {
|
|
290
|
+
const planetLikes = likes.filter((like) => like.planetId === planet.id);
|
|
291
|
+
return {
|
|
292
|
+
id: planet.id,
|
|
293
|
+
userId: planet.userId,
|
|
294
|
+
alienId: planet.alienId,
|
|
295
|
+
discoveryDate: iso(planet.discoveryDate),
|
|
296
|
+
name: planet.name,
|
|
297
|
+
description: planet.description,
|
|
298
|
+
createdAt: requiredIso(planet.createdAt),
|
|
299
|
+
updatedAt: requiredIso(planet.updatedAt),
|
|
300
|
+
image: await resolveImage(ctx, planet.imageId),
|
|
301
|
+
creatorName: planet.creatorName ?? undefined,
|
|
302
|
+
alienName: planet.alienId ? alienById.get(planet.alienId)?.name : undefined,
|
|
303
|
+
likesCount: planetLikes.length,
|
|
304
|
+
likedByMe: planetLikes.some((like) => like.userId === currentUserId),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function loadLookups(db: ApiHandlerContext<unknown, unknown>["db"]) {
|
|
309
|
+
const [alienRows, likeRows] = await Promise.all([
|
|
310
|
+
db.select().from(aliens),
|
|
311
|
+
db.select({ planetId: planetLikes.planetId, userId: planetLikes.userId }).from(planetLikes),
|
|
312
|
+
]);
|
|
313
|
+
return {
|
|
314
|
+
alienById: new Map(alienRows.map((row) => [row.id, row])),
|
|
315
|
+
likes: likeRows,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function enrichOne(
|
|
320
|
+
ctx: ApiHandlerContext<unknown, unknown>,
|
|
321
|
+
planet: PlanetRow,
|
|
322
|
+
currentUserId: string,
|
|
323
|
+
) {
|
|
324
|
+
const lookups = await loadLookups(ctx.db);
|
|
325
|
+
return enrichPlanet(ctx, planet, currentUserId, lookups.alienById, lookups.likes);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function findPlanet(db: ApiHandlerContext<unknown, unknown>["db"], id: string) {
|
|
329
|
+
const [row] = await db.select().from(planets).where(eq(planets.id, id)).limit(1);
|
|
330
|
+
return row ?? null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function validateAlien(
|
|
334
|
+
db: ApiHandlerContext<unknown, unknown>["db"],
|
|
335
|
+
alienId: string | null | undefined,
|
|
336
|
+
) {
|
|
337
|
+
if (!alienId) return null;
|
|
338
|
+
const [row] = await db.select().from(aliens).where(eq(aliens.id, alienId)).limit(1);
|
|
339
|
+
return row ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function validateImage(
|
|
343
|
+
db: ApiHandlerContext<unknown, unknown>["db"],
|
|
344
|
+
image: { id: string } | null | undefined,
|
|
345
|
+
userId: string,
|
|
346
|
+
) {
|
|
347
|
+
if (!image) return null;
|
|
348
|
+
const [row] = await db.select().from(media).where(eq(media.id, image.id)).limit(1);
|
|
349
|
+
if (!row) return { error: fail(404, "Media not found") };
|
|
350
|
+
if (row.userId !== userId) return { error: fail(403, "You can only use your own media") };
|
|
351
|
+
if (row.resourceName !== PLANET_IMAGE_RESOURCE) {
|
|
352
|
+
return { error: fail(400, "Media cannot be used as a planet image") };
|
|
353
|
+
}
|
|
354
|
+
if (!row.uploaded)
|
|
355
|
+
return { error: fail(400, "Media must be uploaded before it can be attached") };
|
|
356
|
+
return { id: row.id };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export { aliens, media, planetLikes, planets };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineApi, z } from "@robodev-ai/sdk";
|
|
2
|
+
import { aliens, ensureSeed } from "../_lib";
|
|
3
|
+
|
|
4
|
+
export const get = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
query: z.object({
|
|
7
|
+
search: z.string().optional(),
|
|
8
|
+
}),
|
|
9
|
+
handler: async (ctx) => {
|
|
10
|
+
await ensureSeed(ctx.db, ctx.user!.id);
|
|
11
|
+
const search = ctx.query.search?.trim().toLocaleLowerCase();
|
|
12
|
+
const rows = await ctx.db.select().from(aliens);
|
|
13
|
+
return rows
|
|
14
|
+
.filter((row) => !search || row.name.toLocaleLowerCase().includes(search))
|
|
15
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
16
|
+
.slice(0, 50)
|
|
17
|
+
.map((row) => ({ id: row.id, label: row.name }));
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineApi, z } from "@robodev-ai/sdk";
|
|
2
|
+
import { media, PLANET_IMAGE_RESOURCE } from "../_lib";
|
|
3
|
+
|
|
4
|
+
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
5
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export const post = defineApi({
|
|
8
|
+
auth: "required",
|
|
9
|
+
body: z.object({
|
|
10
|
+
resourceName: z.string().optional(),
|
|
11
|
+
fileName: z.string().min(1),
|
|
12
|
+
fileSize: z.number().int().positive(),
|
|
13
|
+
mimeType: z.string().optional(),
|
|
14
|
+
method: z.string().optional(),
|
|
15
|
+
}),
|
|
16
|
+
handler: async (ctx) => {
|
|
17
|
+
const mimeType = ctx.body.mimeType || "application/octet-stream";
|
|
18
|
+
if (ctx.body.fileSize > MAX_BYTES) {
|
|
19
|
+
return { status: 400, body: { message: "File is too large" } };
|
|
20
|
+
}
|
|
21
|
+
if (ctx.body.resourceName === PLANET_IMAGE_RESOURCE && !ALLOWED.has(mimeType)) {
|
|
22
|
+
return { status: 400, body: { message: "Unsupported image type" } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const key = `planet-images/${ctx.user!.id}/${crypto.randomUUID()}-${ctx.body.fileName.replaceAll("/", "-")}`;
|
|
26
|
+
const [row] = await ctx.db
|
|
27
|
+
.insert(media)
|
|
28
|
+
.values({
|
|
29
|
+
key,
|
|
30
|
+
userId: ctx.user!.id,
|
|
31
|
+
resourceName: ctx.body.resourceName || PLANET_IMAGE_RESOURCE,
|
|
32
|
+
fileName: ctx.body.fileName,
|
|
33
|
+
fileSize: ctx.body.fileSize,
|
|
34
|
+
mimeType,
|
|
35
|
+
})
|
|
36
|
+
.returning();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: row!.id,
|
|
40
|
+
method: "post",
|
|
41
|
+
url: "/api/files/upload",
|
|
42
|
+
fields: [
|
|
43
|
+
["id", row!.id],
|
|
44
|
+
["key", key],
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineApi, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { fail, media } from "../_lib";
|
|
3
|
+
|
|
4
|
+
export const post = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
multipart: true,
|
|
7
|
+
handler: async (ctx) => {
|
|
8
|
+
const id =
|
|
9
|
+
typeof ctx.body === "object" && ctx.body && "id" in ctx.body ? String(ctx.body.id) : "";
|
|
10
|
+
const file = ctx.files?.[0];
|
|
11
|
+
if (!id || !file) return fail(400, "Missing file upload");
|
|
12
|
+
|
|
13
|
+
const [row] = await ctx.db.select().from(media).where(eq(media.id, id)).limit(1);
|
|
14
|
+
if (!row) return fail(404, "Media not found");
|
|
15
|
+
if (row.userId !== ctx.user!.id) return fail(403, "You can only upload your own media");
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await ctx.storage.upload(row.key, file.data, {
|
|
19
|
+
public: true,
|
|
20
|
+
contentType: file.mimetype || row.mimeType,
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return fail(503, error instanceof Error ? error.message : "Storage is not configured");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await ctx.db.update(media).set({ uploaded: new Date() }).where(eq(media.id, row.id));
|
|
27
|
+
return { ok: true, id: row.id };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { and, defineApi, eq } from "@robodev-ai/sdk";
|
|
2
|
+
import { enrichOne, fail, findPlanet, planetLikes } from "../../_lib";
|
|
3
|
+
|
|
4
|
+
export const post = defineApi({
|
|
5
|
+
auth: "required",
|
|
6
|
+
handler: async (ctx) => {
|
|
7
|
+
const planet = await findPlanet(ctx.db, ctx.params.id);
|
|
8
|
+
if (!planet) return fail(404, "Planet not found");
|
|
9
|
+
const [existing] = await ctx.db
|
|
10
|
+
.select({ id: planetLikes.id })
|
|
11
|
+
.from(planetLikes)
|
|
12
|
+
.where(and(eq(planetLikes.planetId, planet.id), eq(planetLikes.userId, ctx.user!.id)))
|
|
13
|
+
.limit(1);
|
|
14
|
+
if (!existing) {
|
|
15
|
+
await ctx.db.insert(planetLikes).values({
|
|
16
|
+
planetId: planet.id,
|
|
17
|
+
userId: ctx.user!.id,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return enrichOne(ctx, planet, ctx.user!.id);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const unlikePlanet = defineApi({
|
|
25
|
+
auth: "required",
|
|
26
|
+
handler: async (ctx) => {
|
|
27
|
+
const planet = await findPlanet(ctx.db, ctx.params.id);
|
|
28
|
+
if (!planet) return fail(404, "Planet not found");
|
|
29
|
+
await ctx.db
|
|
30
|
+
.delete(planetLikes)
|
|
31
|
+
.where(and(eq(planetLikes.planetId, planet.id), eq(planetLikes.userId, ctx.user!.id)));
|
|
32
|
+
return enrichOne(ctx, planet, ctx.user!.id);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export { unlikePlanet as delete };
|
|
@@ -1,21 +1,64 @@
|
|
|
1
|
-
/** GET /api/planets/:id — one planet by path param. */
|
|
2
1
|
import { defineApi, eq, z } from "@robodev-ai/sdk";
|
|
3
|
-
import { planets } from "
|
|
2
|
+
import { enrichOne, fail, findPlanet, planets, validateAlien, validateImage } from "../_lib";
|
|
4
3
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
const planetBody = z.object({
|
|
5
|
+
name: z.string().min(1),
|
|
6
|
+
alienId: z.string().nullish(),
|
|
7
|
+
discoveryDate: z.string().nullish(),
|
|
8
|
+
description: z.string().nullish(),
|
|
9
|
+
image: z.object({ id: z.string() }).nullish(),
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
export const get = defineApi({
|
|
13
|
-
|
|
14
|
-
handler: async (
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
17
|
-
|
|
13
|
+
auth: "required",
|
|
14
|
+
handler: async (ctx) => {
|
|
15
|
+
const planet = await findPlanet(ctx.db, ctx.params.id);
|
|
16
|
+
if (!planet) return fail(404, "Planet not found");
|
|
17
|
+
return enrichOne(ctx, planet, ctx.user!.id);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const put = defineApi({
|
|
22
|
+
auth: "required",
|
|
23
|
+
body: planetBody,
|
|
24
|
+
handler: async (ctx) => {
|
|
25
|
+
const planet = await findPlanet(ctx.db, ctx.params.id);
|
|
26
|
+
if (!planet) return fail(404, "Planet not found");
|
|
27
|
+
if (planet.userId !== ctx.user!.id) return fail(403, "You can only update your own planets");
|
|
28
|
+
|
|
29
|
+
const alienId = ctx.body.alienId || null;
|
|
30
|
+
if (alienId && !(await validateAlien(ctx.db, alienId))) {
|
|
31
|
+
return fail(404, "Alien not found");
|
|
18
32
|
}
|
|
19
|
-
|
|
33
|
+
const image = await validateImage(ctx.db, ctx.body.image, ctx.user!.id);
|
|
34
|
+
if (image && "error" in image) return image.error;
|
|
35
|
+
|
|
36
|
+
const [updated] = await ctx.db
|
|
37
|
+
.update(planets)
|
|
38
|
+
.set({
|
|
39
|
+
name: ctx.body.name,
|
|
40
|
+
alienId,
|
|
41
|
+
discoveryDate: ctx.body.discoveryDate ? new Date(ctx.body.discoveryDate) : null,
|
|
42
|
+
description: ctx.body.description ?? null,
|
|
43
|
+
imageId: image?.id ?? null,
|
|
44
|
+
updatedAt: new Date(),
|
|
45
|
+
})
|
|
46
|
+
.where(eq(planets.id, planet.id))
|
|
47
|
+
.returning();
|
|
48
|
+
if (!updated) return fail(404, "Planet not found");
|
|
49
|
+
return enrichOne(ctx, updated, ctx.user!.id);
|
|
20
50
|
},
|
|
21
51
|
});
|
|
52
|
+
|
|
53
|
+
const deletePlanet = defineApi({
|
|
54
|
+
auth: "required",
|
|
55
|
+
handler: async (ctx) => {
|
|
56
|
+
const planet = await findPlanet(ctx.db, ctx.params.id);
|
|
57
|
+
if (!planet) return fail(404, "Planet not found");
|
|
58
|
+
if (planet.userId !== ctx.user!.id) return fail(403, "You can only delete your own planets");
|
|
59
|
+
await ctx.db.delete(planets).where(eq(planets.id, planet.id));
|
|
60
|
+
return { status: 204, body: null };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export { deletePlanet as delete };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineApi } from "@robodev-ai/sdk";
|
|
2
|
+
import {
|
|
3
|
+
ensureSeed,
|
|
4
|
+
enrichPlanet,
|
|
5
|
+
filterPlanetRows,
|
|
6
|
+
loadLookups,
|
|
7
|
+
paginate,
|
|
8
|
+
parseFilter,
|
|
9
|
+
planets,
|
|
10
|
+
sortPlanets,
|
|
11
|
+
} from "../_lib";
|
|
12
|
+
|
|
13
|
+
export const get = defineApi({
|
|
14
|
+
auth: "required",
|
|
15
|
+
handler: async (ctx) => {
|
|
16
|
+
await ensureSeed(ctx.db, ctx.user!.id);
|
|
17
|
+
const query = ctx.query as Record<string, unknown>;
|
|
18
|
+
const rows = await ctx.db.select().from(planets);
|
|
19
|
+
const lookups = await loadLookups(ctx.db);
|
|
20
|
+
const filter = parseFilter(query);
|
|
21
|
+
const filtered = filterPlanetRows(rows, lookups.alienById, filter);
|
|
22
|
+
const page = paginate(
|
|
23
|
+
sortPlanets(filtered, typeof query.order === "string" ? query.order : undefined),
|
|
24
|
+
Number(query.page) || undefined,
|
|
25
|
+
Number(query.limit) || undefined,
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
...page,
|
|
29
|
+
items: await Promise.all(
|
|
30
|
+
page.items.map((row) =>
|
|
31
|
+
enrichPlanet(ctx, row, ctx.user!.id, lookups.alienById, lookups.likes),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|