Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions db/migrations/0002_saved_search.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "saved_search" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"tech" text NOT NULL,
"label" text NOT NULL,
"sort" text NOT NULL,
"linked_pr" text NOT NULL,
"hacktoberfest" text NOT NULL,
"created_at" integer NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON UPDATE no action ON DELETE cascade
);

CREATE INDEX IF NOT EXISTS "saved_search_userId_idx" ON "saved_search" ("user_id");
Binary file added public/openissue-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ sonar.sources=src
sonar.tests=tests
sonar.test.inclusions=tests/**/*.test.ts
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/features/issues/types/**,src/features/issues/data/**
sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/**
sonar.sourceEncoding=UTF-8
25 changes: 25 additions & 0 deletions src/app/api/saved-searches/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { and, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { savedSearch } from "@/lib/auth-schema";
import { getDatabase } from "@/lib/db";

export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: request.headers });

if (!session) {
return Response.json({ error: "Unauthorized." }, { status: 401 });
}

const { id } = await params;

await getDatabase()
.delete(savedSearch)
.where(
and(eq(savedSearch.id, id), eq(savedSearch.userId, session.user.id)),
);

return new Response(null, { status: 204 });
}
87 changes: 87 additions & 0 deletions src/app/api/saved-searches/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { asc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { savedSearch } from "@/lib/auth-schema";
import { getDatabase } from "@/lib/db";
import {
isValidSavedSearch,
type SavedSearch,
} from "@/features/issues/lib/saved-searches";

const MAX_SAVED_SEARCHES_PER_SYNC = 100;
const MAX_TEXT_LENGTH = 200;

function isSafeSavedSearch(value: unknown): value is SavedSearch {
if (!isValidSavedSearch(value)) return false;

return (
value.id.length > 0 &&
value.id.length <= MAX_TEXT_LENGTH &&
value.name.length > 0 &&
value.name.length <= MAX_TEXT_LENGTH &&
value.tech.length > 0 &&
value.tech.length <= MAX_TEXT_LENGTH &&
!Number.isNaN(Date.parse(value.createdAt))
);
}

async function listSavedSearches(userId: string): Promise<SavedSearch[]> {
const rows = await getDatabase()
.select()
.from(savedSearch)
.where(eq(savedSearch.userId, userId))
.orderBy(asc(savedSearch.createdAt));

return rows.map((row) => ({
id: row.id,
name: row.name,
tech: row.tech,
label: row.label,
sort: row.sort,
linkedPr: row.linkedPr,
hacktoberfest: row.hacktoberfest,
createdAt: row.createdAt.toISOString(),
}));
}

export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });

if (!session) {
return Response.json({ error: "Unauthorized." }, { status: 401 });
}

let body: unknown;

try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid request body." }, { status: 400 });
}

const searches = (body as { searches?: unknown } | null)?.searches;

if (
!Array.isArray(searches) ||
searches.length > MAX_SAVED_SEARCHES_PER_SYNC ||
Comment thread
arnabnandy7 marked this conversation as resolved.
!searches.every(isSafeSavedSearch)
) {
return Response.json({ error: "Invalid saved searches." }, { status: 400 });
}

const database = getDatabase();

for (const search of searches) {
await database
.insert(savedSearch)
.values({
...search,
userId: session.user.id,
createdAt: new Date(search.createdAt),
})
.onConflictDoNothing();
}

return Response.json({
searches: await listSavedSearches(session.user.id),
});
}
Binary file added src/app/apple-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/app/favicon.ico
Binary file not shown.
Binary file added src/app/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 75 additions & 9 deletions src/features/issues/components/issue-finder.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { FormEvent, useEffect, useMemo, useState } from "react";
import { Bookmark, GitPullRequest, Search, Trash2 } from "lucide-react";
import Image from "next/image";
import { Bookmark, Search, Trash2 } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { AuthControls } from "@/components/auth-controls";
import { Badge } from "@/components/ui/badge";
Expand All @@ -10,8 +11,13 @@ import {
addSavedSearch,
deleteSavedSearch,
getSavedSearches,
replaceSavedSearches,
type SavedSearch,
} from "@/features/issues/lib/saved-searches";
import {
deleteCloudSavedSearch,
syncSavedSearches,
} from "@/features/issues/lib/saved-search-cloud";
import {
Card,
CardContent,
Expand Down Expand Up @@ -40,8 +46,10 @@ import {
import { compactNumber } from "@/features/issues/lib/format";
import { mergeRankedIssues, rankIssues } from "@/features/issues/lib/ranking";
import type { SearchResponse, Issue } from "@/features/issues/types/search";
import { authClient } from "@/lib/auth-client";

export function IssueFinder() {
const { data: session, isPending: isSessionPending } = authClient.useSession();
const [tech, setTech] = useState("Java");
const [label, setLabel] = useState("help-wanted");
const [sort, setSort] = useState("updated");
Expand All @@ -59,9 +67,36 @@ export function IssueFinder() {
const [savedSearchName, setSavedSearchName] = useState("");

useEffect(() => {
// Hydration must start with the server's empty snapshot before reading browser storage.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSavedSearches(getSavedSearches());
}, []);

useEffect(() => {
if (isSessionPending || !session?.user.id) return;

let cancelled = false;

async function syncWithAccount() {
try {
const syncedSearches = await syncSavedSearches(getSavedSearches());

if (!cancelled) {
replaceSavedSearches(syncedSearches);
setSavedSearches(syncedSearches);
}
} catch {
// Local saved searches remain available if account sync is unavailable.
}
}

void syncWithAccount();

return () => {
cancelled = true;
};
}, [isSessionPending, session?.user.id]);

const selectedLabel = useMemo(
() =>
LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0],
Expand Down Expand Up @@ -114,18 +149,43 @@ export function IssueFinder() {
setSavedSearches((current) => [...current, savedSearch]);
setSavedSearchName("");
setError(null);
}catch (saveError) {

if (session?.user.id) {
void syncSavedSearches(getSavedSearches())
.then((syncedSearches) => {
replaceSavedSearches(syncedSearches);
setSavedSearches(syncedSearches);
})
.catch(() => {
setError("Search saved locally, but account sync failed.");
});
}
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: "Unable to save search.",
? saveError.message
: "Unable to save search.",
);
}
}

function handleDeleteSavedSearch(id: string) {
async function handleDeleteSavedSearch(id: string) {
if (session?.user.id) {
try {
await deleteCloudSavedSearch(id);
} catch (deleteError) {
setError(
deleteError instanceof Error
? deleteError.message
: "Unable to remove the saved search from your account.",
);
return;
}
}

deleteSavedSearch(id);
setSavedSearches(getSavedSearches());
setError(null);
}

function handleRunSavedSearch(savedSearch: SavedSearch) {
Expand Down Expand Up @@ -252,7 +312,13 @@ export function IssueFinder() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1.5">
<GitPullRequest className="h-3.5 w-3.5" />
<Image
src="/openissue-logo.png"
alt=""
width={16}
height={16}
className="h-4 w-4"
/>
OSS Issue Finder
</Badge>
<Badge variant="outline">GitHub Search API</Badge>
Expand Down Expand Up @@ -513,9 +579,9 @@ export function IssueFinder() {
variant="outline"
size="sm"
aria-label={`Delete ${savedSearch.name}`}
onClick={() =>
handleDeleteSavedSearch(savedSearch.id)
}
onClick={() => {
void handleDeleteSavedSearch(savedSearch.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
Expand Down
46 changes: 46 additions & 0 deletions src/features/issues/lib/saved-search-cloud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SavedSearch } from "@/features/issues/lib/saved-searches";

const MAX_SEARCHES_PER_REQUEST = 100;

export async function syncSavedSearches(
searches: SavedSearch[],
): Promise<SavedSearch[]> {
const batches = searches.length
? Array.from(
{ length: Math.ceil(searches.length / MAX_SEARCHES_PER_REQUEST) },
(_, index) =>
searches.slice(
index * MAX_SEARCHES_PER_REQUEST,
(index + 1) * MAX_SEARCHES_PER_REQUEST,
),
)
: [[]];
let syncedSearches: SavedSearch[] = [];

for (const batch of batches) {
const response = await fetch("/api/saved-searches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ searches: batch }),
});

if (!response.ok) {
throw new Error("Unable to sync saved searches.");
}

const result = (await response.json()) as { searches: SavedSearch[] };
syncedSearches = result.searches;
}

return syncedSearches;
}

export async function deleteCloudSavedSearch(id: string): Promise<void> {
const response = await fetch(`/api/saved-searches/${encodeURIComponent(id)}`, {
method: "DELETE",
});

if (!response.ok) {
throw new Error("Unable to remove the saved search from your account.");
}
}
8 changes: 7 additions & 1 deletion src/features/issues/lib/saved-searches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type SavedSearch = {

const STORAGE_KEY = "openissue:saved-searches";

function isValidSavedSearch(value: unknown): value is SavedSearch {
export function isValidSavedSearch(value: unknown): value is SavedSearch {
if (!value || typeof value !== "object") {
return false;
}
Expand Down Expand Up @@ -79,6 +79,12 @@ function saveSavedSearches(searches: SavedSearch[]): boolean {
}
}

export function replaceSavedSearches(searches: SavedSearch[]): void {
if (!saveSavedSearches(searches)) {
throw new Error("Unable to update saved searches.");
}
}

let fallbackIdCounter = 0;

function createSavedSearchId(): string {
Expand Down
26 changes: 26 additions & 0 deletions src/lib/auth-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,28 @@ export const verification = sqliteTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);

export const savedSearch = sqliteTable(
"saved_search",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
tech: text("tech").notNull(),
label: text("label").notNull(),
sort: text("sort").notNull(),
linkedPr: text("linked_pr").notNull(),
hacktoberfest: text("hacktoberfest").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [index("saved_search_userId_idx").on(table.userId)],
);

export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
savedSearches: many(savedSearch),
}));

export const sessionRelations = relations(session, ({ one }) => ({
Expand All @@ -118,3 +137,10 @@ export const accountRelations = relations(account, ({ one }) => ({
references: [user.id],
}),
}));

export const savedSearchRelations = relations(savedSearch, ({ one }) => ({
user: one(user, {
fields: [savedSearch.userId],
references: [user.id],
}),
}));
Loading