Feedtack connects to any backend via the FeedtackAdapter interface. Implement five methods to wire Feedtack to your database, API, or file system.
The interface
import type { FeedtackAdapter } from "feedtack";
interface FeedtackAdapter {
submit(payload: FeedtackPayload): Promise<void>;
reply(feedbackId: string, reply: Omit<FeedtackReply, "id" | "feedbackId">): Promise<void>;
resolve(feedbackId: string, resolution: Omit<FeedtackResolution, "feedbackId">): Promise<void>;
archive(feedbackId: string, userId: string): Promise<void>;
loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
}Recipe: Disk / JSON files (Node.js)
Git-trackable feedback — each submission becomes a JSON file in .feedback/.
import type { FeedtackAdapter, FeedbackItem, FeedtackPayload } from "feedtack";
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
const DIR = ".feedback";
class DiskAdapter implements FeedtackAdapter {
async submit(payload: FeedtackPayload) {
await mkdir(DIR, { recursive: true });
const item: FeedbackItem = { payload, replies: [], resolutions: [], archives: [] };
await writeFile(join(DIR, `${payload.id}.json`), JSON.stringify(item, null, 2));
}
async reply(feedbackId: string, reply: Omit<FeedbackItem["replies"][0], "id" | "feedbackId">) {
const item = await this.read(feedbackId);
item.replies.push({ ...reply, id: crypto.randomUUID(), feedbackId });
await this.write(feedbackId, item);
}
async resolve(
feedbackId: string,
resolution: Omit<FeedbackItem["resolutions"][0], "feedbackId">,
) {
const item = await this.read(feedbackId);
item.resolutions.push({ ...resolution, feedbackId });
await this.write(feedbackId, item);
}
async archive(feedbackId: string, userId: string) {
const item = await this.read(feedbackId);
item.archives.push({
feedbackId,
archivedBy: { id: userId, name: "", role: "" },
timestamp: new Date().toISOString(),
});
await this.write(feedbackId, item);
}
async loadFeedback() {
await mkdir(DIR, { recursive: true });
const files = (await readdir(DIR)).filter((f) => f.endsWith(".json"));
return Promise.all(
files.map(async (f) => JSON.parse(await readFile(join(DIR, f), "utf-8")) as FeedbackItem),
);
}
private async read(id: string) {
return JSON.parse(await readFile(join(DIR, `${id}.json`), "utf-8")) as FeedbackItem;
}
private async write(id: string, item: FeedbackItem) {
await writeFile(join(DIR, `${id}.json`), JSON.stringify(item, null, 2));
}
}Recipe: Supabase
import type { FeedtackAdapter, FeedbackItem, FeedtackFilter, FeedtackPayload } from "feedtack";
import type { SupabaseClient } from "@supabase/supabase-js";
class SupabaseAdapter implements FeedtackAdapter {
constructor(private supabase: SupabaseClient) {}
async submit(payload: FeedtackPayload) {
await this.supabase.from("feedtack_submissions").insert({ id: payload.id, data: payload });
}
async reply(
feedbackId: string,
reply: Omit<FeedbackItem["replies"][0], "id" | "feedbackId">,
) {
await this.supabase.from("feedtack_replies").insert({ feedback_id: feedbackId, ...reply });
}
async resolve(
feedbackId: string,
resolution: Omit<FeedbackItem["resolutions"][0], "feedbackId">,
) {
await this.supabase
.from("feedtack_resolutions")
.insert({ feedback_id: feedbackId, ...resolution });
}
async archive(feedbackId: string, userId: string) {
await this.supabase
.from("feedtack_archives")
.insert({ feedback_id: feedbackId, user_id: userId });
}
async loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]> {
let query = this.supabase
.from("feedtack_submissions")
.select("*, feedtack_replies(*), feedtack_resolutions(*), feedtack_archives(*)");
if (filter?.pathname) query = query.filter("data->page->>pathname", "eq", filter.pathname);
const { data } = await query;
return (data ?? []).map((row) => ({
payload: row.data,
replies: row.feedtack_replies ?? [],
resolutions: row.feedtack_resolutions ?? [],
archives: row.feedtack_archives ?? [],
}));
}
}