Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSE Valildation #104

Merged
merged 1 commit into from
Jan 28, 2024
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
12 changes: 2 additions & 10 deletions src/lib/components/wishlists/ItemCard/ItemCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
</script>

<script lang="ts">
import { invalidateAll } from "$app/navigation";
import {
getDrawerStore,
getModalStore,
Expand All @@ -23,6 +22,7 @@
import { ItemAPI } from "$lib/api/items";
import ApprovalButtons from "./ApprovalButtons.svelte";
import ClaimButtons from "./ClaimButtons.svelte";
import { invalidateAll } from "$app/navigation";

export let item: FullItem;
export let user: PartialUser & { userId: string };
Expand Down Expand Up @@ -89,8 +89,6 @@
const resp = await (approve ? itemAPI.approve() : itemAPI.deny());

if (resp.ok) {
invalidateAll();

toastStore.trigger({
message: `${item.name} was ${approve ? "approved" : "denied"}`,
autohide: true,
Expand Down Expand Up @@ -121,8 +119,6 @@
const resp = await (unclaim ? itemAPI.unclaim() : itemAPI.claim(user.userId));

if (resp.ok) {
invalidateAll();

toastStore.trigger({
message: `${unclaim ? "Unclaimed" : "Claimed"} item`,
autohide: true,
Expand All @@ -134,11 +130,7 @@
};

const handlePurchased = async (purchased: boolean) => {
const resp = await (purchased ? itemAPI.purchase() : itemAPI.unpurchase());

if (resp.ok) {
invalidateAll();
}
await (purchased ? itemAPI.purchase() : itemAPI.unpurchase());
};
</script>

Expand Down
10 changes: 6 additions & 4 deletions src/lib/server/events/sse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// https://github.com/sanrafa/sveltekit-sse-example/tree/main
import type EventEmitter from "node:events";

export function createSSE(retry = 0) {
export function createSSE<T>(retry = 0) {
const { readable, writable } = new TransformStream({
start(ctr) {
if (retry > 0) ctr.enqueue(`retry: ${retry}\n\n`);
Expand All @@ -22,9 +22,11 @@ export function createSSE(retry = 0) {

return {
readable,
async subscribeToEvent(emitter: EventEmitter, event: string) {
function listener(data: any) {
writer.write({ event, data });
async subscribeToEvent(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean) {
function listener(data: T) {
if (!predicate || (predicate && predicate(data))) {
writer.write({ event, data });
}
}
emitter.on(event, listener);
await writer.closed.catch((e) => {
Expand Down
12 changes: 8 additions & 4 deletions src/routes/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PageServerLoad } from "./$types";

import { client } from "$lib/server/prisma";
import { getActiveMembership } from "$lib/server/group-membership";
import { getConfig } from "$lib/server/config";

export const load = (async ({ locals }) => {
const session = await locals.validate();
Expand All @@ -12,6 +13,7 @@ export const load = (async ({ locals }) => {
const user = session.user;

const activeMembership = await getActiveMembership(user);
const config = await getConfig(activeMembership.groupId);

const userQuery = client.user.findUniqueOrThrow({
select: {
Expand All @@ -23,9 +25,10 @@ export const load = (async ({ locals }) => {
id: true
},
where: {
addedBy: {
username: user.username
},
addedBy:
config.suggestions.enable && config.suggestions.method === "surprise"
? { username: user.username }
: undefined,
groupId: activeMembership.groupId
}
},
Expand Down Expand Up @@ -68,7 +71,8 @@ export const load = (async ({ locals }) => {
id: true
},
where: {
groupId: activeMembership.groupId
groupId: activeMembership.groupId,
approved: true
}
},
_count: {
Expand Down
18 changes: 0 additions & 18 deletions src/routes/api/events/+server.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/routes/api/items/[itemId]/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
},
select: {
id: true,
groupId: true,
userId: true,
addedBy: {
select: {
username: true
Expand Down
3 changes: 1 addition & 2 deletions src/routes/wishlists/[username]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,7 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
isMe: params.username === session.user.username,
...listOwner
},
items: wishlistItems.filter((item) => item.approved),
approvals: wishlistItems.filter((item) => !item.approved),
items: wishlistItems,
suggestionsEnabled: config.suggestions.enable,
showClaimedName: config.claims.showName,
groupId: activeMembership.groupId
Expand Down
60 changes: 39 additions & 21 deletions src/routes/wishlists/[username]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

export let data: PageData;
type Item = PageData["items"][0];
$: allItems = data.items;
$: approvals = allItems.filter((item) => !item.approved);
$: items = allItems.filter((item) => item.approved);

const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
Expand All @@ -37,31 +40,41 @@

let eventSource: EventSource;
onMount(async () => {
const userHash = await hash(data.listOwner.id + data.groupId);
$viewedItems[userHash] = await hashItems(data.items);

await updateHash();
subscribeToEvents();
});
onDestroy(() => eventSource?.close());

const updateHash = async () => {
const userHash = await hash(data.listOwner.id + data.groupId);
$viewedItems[userHash] = await hashItems(allItems);
};

const subscribeToEvents = () => {
eventSource = new EventSource("/api/events");
eventSource = new EventSource(`${$page.url.pathname}/events`);
eventSource.addEventListener(SSEvents.item.update, (e) => {
const message = JSON.parse(e.data) as Item;
updateItems(message);
updateHash();
});
eventSource.addEventListener(SSEvents.item.delete, (e) => {
const message = JSON.parse(e.data) as Item;
removeItem(message);
updateHash();
});
eventSource.addEventListener(SSEvents.item.create, (e) => {
const message = JSON.parse(e.data) as Item;
addItem(message);
updateHash();
});
};

const updateItems = (updatedItem: Item) => {
data.items = data.items.map((item) => {
// for when an item gets approved
if (!allItems.find((item) => item.id === updatedItem.id)) {
addItem(updatedItem);
}
allItems = allItems.map((item) => {
if (item.id === updatedItem.id) {
return { ...item, ...updatedItem };
}
Expand All @@ -70,18 +83,31 @@
};

const removeItem = (removedItem: Item) => {
data.items = data.items.filter((item) => item.id !== removedItem.id);
allItems = allItems.filter((item) => item.id !== removedItem.id);
};

const addItem = (addedItem: Item) => {
data.items = [...data.items, addedItem];
if (!(addedItem.approved || data.listOwner.isMe)) {
return;
}
allItems = [...allItems, addedItem];
};
</script>

{#if data.approvals.length > 0}
<!-- chips -->
{#if allItems.length > 0}
<div class="flex flex-row flex-wrap space-x-4">
{#if !data.listOwner.isMe}
<ClaimFilterChip />
{/if}
<SortBy />
</div>
{/if}

{#if approvals.length > 0}
<h2 class="h2 pb-2">Approvals</h2>
<div class="flex flex-col space-y-4 pb-2">
{#each data.approvals as item (item.id)}
{#each approvals as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
Expand All @@ -90,37 +116,29 @@
<hr class="pb-2" />
{/if}

{#if data.items.length === 0}
{#if items.length === 0}
<div class="flex flex-col items-center justify-center space-y-4 pt-4">
<img class="w-3/4 md:w-1/3" alt="Two people looking in an empty box" src={empty} />
<p class="text-2xl">No wishes yet</p>
</div>
{:else}
<!-- chips -->
<div class="flex flex-row flex-wrap space-x-4">
{#if !data.listOwner.isMe}
<ClaimFilterChip />
{/if}
<SortBy />
</div>

<!-- items -->
<div class="flex flex-col space-y-4">
{#if data.listOwner.isMe}
{#each data.items as item (item.id)}
{#each items as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
{/each}
{:else}
<!-- unclaimed-->
{#each data.items.filter((item) => !item.pledgedById) as item (item.id)}
{#each items.filter((item) => !item.pledgedById) as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
{/each}
<!-- claimed -->
{#each data.items.filter((item) => item.pledgedById) as item (item.id)}
{#each items.filter((item) => item.pledgedById) as item (item.id)}
<div in:receive={{ key: item.id }} out:send|local={{ key: item.id }} animate:flip={{ duration: 200 }}>
<ItemCard {item} showClaimedName={data.showClaimedName} user={data.user} />
</div>
Expand Down
63 changes: 63 additions & 0 deletions src/routes/wishlists/[username]/events/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SSEvents } from "$lib/schema";
import { itemEmitter } from "$lib/server/events/emitters";
import { createSSE } from "$lib/server/events/sse";
import type { Item } from "@prisma/client";
import type { RequestHandler } from "./$types";
import { error } from "@sveltejs/kit";
import { client } from "$lib/server/prisma";
import { getConfig } from "$lib/server/config";

export const GET = (async ({ locals, params }) => {
const session = await locals.validate();
if (!session) {
error(401, "Unauthorized");
}

const [wishlistUser, activeGroup] = await Promise.all([
client.user.findUniqueOrThrow({
select: {
id: true
},
where: {
username: params.username
}
}),
client.userGroupMembership.findFirstOrThrow({
select: {
groupId: true
},
where: {
userId: session.user.userId,
active: true
}
})
]);

const config = await getConfig(activeGroup.groupId);
if (
config.suggestions.enable &&
config.suggestions.method === "surprise" &&
params.username === session.user.username
) {
return new Response();
}

const predicate = (item: Item) => {
const b = activeGroup.groupId === item.groupId && wishlistUser.id === item.userId;
return b;
};

const { readable, subscribeToEvent } = createSSE<Item>();

subscribeToEvent(itemEmitter, SSEvents.item.update, predicate);
subscribeToEvent(itemEmitter, SSEvents.item.create, predicate);
subscribeToEvent(itemEmitter, SSEvents.item.delete, predicate);

return new Response(readable, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Accel-Buffering": "no"
}
});
}) satisfies RequestHandler;
Loading