Skip to content

Commit 7758578

Browse files
author
Filip
committed
feat: integrate feature permission for ai summary
1 parent 883a76d commit 7758578

File tree

9 files changed

+120
-6
lines changed

9 files changed

+120
-6
lines changed

apps/backend/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { appFactory } from "./app-factory";
66
import { getAuth } from "./auth";
77
import { linksApp } from "./links";
88
import { customLogger } from "./logger";
9+
import {
10+
type FeaturePermissionName,
11+
featurePermissionsApp,
12+
} from "./permissions/permissions";
913
import { tagsApp } from "./tags";
1014

1115
const authApp = appFactory
@@ -69,6 +73,7 @@ const app = new Hono<{
6973
.route("/", authApp)
7074
.route("/", linksApp)
7175
.route("/", tagsApp)
76+
.route("/", featurePermissionsApp)
7277
.get("/", (c) => {
7378
return c.text("Hello Hono!");
7479
})
@@ -81,4 +86,6 @@ const app = new Hono<{
8186
});
8287

8388
export type AppType = typeof app;
89+
8490
export default app;
91+
export type { FeaturePermissionName };

apps/backend/src/links.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { zValidator } from "@hono/zod-validator";
33
import { generateText } from "ai";
44
import z from "zod";
55
import { appFactory } from "./app-factory";
6+
import { canCreateAiSummary } from "./permissions/ai-summary-permissions";
67

78
export const linksApp = appFactory
89
.createApp()
@@ -131,6 +132,19 @@ export const linksApp = appFactory
131132
return c.json({ error: "Unauthorized" }, 401);
132133
}
133134

135+
if (
136+
!canCreateAiSummary({
137+
userId: user.id,
138+
prisma: c.get("prisma"),
139+
link: { userId: user.id },
140+
})
141+
) {
142+
return c.json(
143+
{ error: "You are not authorized to create an AI summary" },
144+
401,
145+
);
146+
}
147+
134148
const { id } = c.req.valid("param");
135149

136150
const link = await c.get("prisma").link.findUnique({ where: { id } });
@@ -145,7 +159,7 @@ export const linksApp = appFactory
145159
});
146160

147161
const { text } = await generateText({
148-
model: groq("openai/gpt-oss-20b"),
162+
model: groq("openai/gpt-oss-120b"),
149163
prompt: `
150164
Summarize the content at this URL: ${link.url}.
151165

apps/backend/src/permissions/ai-summary-permissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { PrismaClient } from "../generated/prisma";
22

3-
const aiSummaryFeatureName = "ai_summary";
3+
export const aiSummaryFeatureName = "ai_summary";
44

55
export async function canCreateAiSummary({
66
userId,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { appFactory } from "../app-factory";
2+
import { aiSummaryFeatureName } from "./ai-summary-permissions";
3+
4+
export type FeaturePermissionName = typeof aiSummaryFeatureName;
5+
6+
export const featurePermissionsApp = appFactory
7+
.createApp()
8+
.get("/feature-permissions", async (c) => {
9+
const user = c.get("user");
10+
if (!user) {
11+
return c.json({ error: "Unauthorized" }, 401);
12+
}
13+
14+
const permissions = await c.get("prisma").featurePermission.findMany({
15+
where: {
16+
users: {
17+
some: {
18+
id: user.id,
19+
},
20+
},
21+
},
22+
});
23+
24+
return c.json({ permissions });
25+
});

apps/backend/src/protected.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { appFactory } from "./app-factory";
2+
3+
export const protectedMiddleware = appFactory.createMiddleware(
4+
async (c, next) => {
5+
const user = c.get("user");
6+
if (!user) {
7+
return c.json({ error: "Unauthorized" }, 401);
8+
}
9+
10+
return next();
11+
},
12+
);

apps/web-app/app/components/ai-summary.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@ export function GenerateAiSummaryButton({
1313
isPending,
1414
type,
1515
onPress,
16+
enabled,
1617
}: {
1718
isPending: boolean;
1819
type: "button";
1920
onPress: () => void;
21+
enabled: boolean;
2022
}) {
23+
if (!enabled) {
24+
return null;
25+
}
26+
2127
return (
2228
<Tooltip closeDelay={150} content="AI Summary">
2329
<Button

apps/web-app/app/components/link-card.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface LinkCardProps {
1818
aiSummary: {
1919
handler: () => void;
2020
isPending: boolean;
21+
enabled: boolean;
2122
};
2223
}
2324

@@ -37,6 +38,7 @@ export function LinkCard({ link, onEdit, onDelete, aiSummary }: LinkCardProps) {
3738
type="button"
3839
onPress={aiSummary.handler}
3940
isPending={aiSummary.isPending}
41+
enabled={aiSummary.enabled}
4042
/>
4143
<Tooltip closeDelay={150} content="Edit">
4244
<Button isIconOnly onPress={() => onEdit(link)}>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { FeaturePermissionName } from "@repo/backend";
2+
import { err, ok, type Result } from "@repo/type-safe-errors";
3+
import { apiClient } from "./api-client";
4+
import { createQuery } from "./cache";
5+
6+
type FeaturePermission = {
7+
id: string;
8+
featureName: string;
9+
createdAt: string;
10+
updatedAt: string;
11+
};
12+
13+
async function getAllFeaturePermissions(): Promise<
14+
Result<FeaturePermission[], string>
15+
> {
16+
const res = await apiClient["feature-permissions"].$get();
17+
const data = await res.json();
18+
19+
if ("error" in data) return err(data.error);
20+
21+
return ok(data.permissions);
22+
}
23+
24+
export const featurePermissionsQuery = createQuery({
25+
cacheKey: "feature-permissions",
26+
fetcher: getAllFeaturePermissions,
27+
});
28+
29+
export function hasFeaturePermission(
30+
featureName: FeaturePermissionName,
31+
featurePermissions: FeaturePermission[],
32+
): boolean {
33+
return featurePermissions.some(
34+
(permission) => permission.featureName === featureName,
35+
);
36+
}

apps/web-app/app/routes/links.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,44 @@ import { AiSummaryModal } from "~/components/ai-summary";
66
import { LinkCard } from "~/components/link-card";
77
import { LinkTable } from "~/components/link-table";
88
import { TagPicker } from "~/components/tag-picker";
9+
import {
10+
featurePermissionsQuery,
11+
hasFeaturePermission,
12+
} from "~/data/feature-permissions";
913
import { linksQuery } from "~/data/links";
1014
import { tagsQuery } from "~/data/tags";
1115
import type { Link } from "~/data/types";
1216
import type { Route } from "./+types/links";
1317
import { useGenerateAiSummary } from "./ai-summary.$linkId";
1418

1519
export async function clientLoader(_: Route.ClientLoaderArgs) {
16-
const [links, tags] = await Promise.all([
20+
const [links, tags, featurePermissions] = await Promise.all([
1721
linksQuery.getData(),
1822
tagsQuery.getData(),
23+
featurePermissionsQuery.getData(),
1924
]);
2025

21-
return { links, tags };
26+
return { links, tags, featurePermissions };
2227
}
2328

2429
export default function Links() {
25-
const { links, tags } = useLoaderData<typeof clientLoader>();
30+
const { links, tags, featurePermissions } =
31+
useLoaderData<typeof clientLoader>();
2632
const navigate = useNavigate();
2733
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
2834
const [searchQuery, setSearchQuery] = useState("");
2935
const [selectedTagFilters, setSelectedTagFilters] = useState<string[]>([]);
3036
const aiSummary = useGenerateAiSummary();
3137

32-
if (!links.ok || !tags.ok) {
38+
if (!links.ok || !tags.ok || !featurePermissions.ok) {
3339
throw new Error("Failed to load data, please try refreshing the page.");
3440
}
3541

42+
const canCreateAiSummary = hasFeaturePermission(
43+
"ai_summary",
44+
featurePermissions.value,
45+
);
46+
3647
const filteredLinks = links.value.filter((link) => {
3748
const matchesSearch =
3849
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -86,6 +97,7 @@ export default function Links() {
8697
onEdit={handleEdit}
8798
onDelete={handleDelete}
8899
aiSummary={{
100+
enabled: canCreateAiSummary,
89101
isPending: aiSummary.isPending(link.id),
90102
handler: () => aiSummary.submit(link),
91103
}}

0 commit comments

Comments
 (0)