From 05442bd57d991ff0f15a67e8b1fdbc6c19bf9f5b Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sat, 8 Feb 2025 23:28:33 +1100
Subject: [PATCH 01/24] Add teams page
---
.../dialogs/manage-team-dialog.component.tsx | 258 +++++++++++
apps/web/components/entity/empty-state.tsx | 24 +-
.../components/layout/header.component.tsx | 1 +
apps/web/data/routes.data.ts | 3 +
apps/web/data/schema.ts | 7 +-
apps/web/pages/_app.tsx | 38 +-
apps/web/pages/pages/index.tsx | 3 +-
apps/web/pages/teams/index.tsx | 419 ++++++++++++++++++
apps/web/utils/useDatabase.ts | 2 +-
packages/supabase/migrations/16_teams.sql | 71 +++
packages/supabase/types/index.ts | 144 +++++-
packages/supabase/types/page.ts | 4 +
12 files changed, 930 insertions(+), 44 deletions(-)
create mode 100644 apps/web/components/dialogs/manage-team-dialog.component.tsx
create mode 100644 apps/web/pages/teams/index.tsx
create mode 100644 packages/supabase/migrations/16_teams.sql
diff --git a/apps/web/components/dialogs/manage-team-dialog.component.tsx b/apps/web/components/dialogs/manage-team-dialog.component.tsx
new file mode 100644
index 0000000..05b75df
--- /dev/null
+++ b/apps/web/components/dialogs/manage-team-dialog.component.tsx
@@ -0,0 +1,258 @@
+import { ITeam } from "@changes-page/supabase/types/page";
+import { Dialog, Transition } from "@headlessui/react";
+import { PlusIcon } from "@heroicons/react/outline";
+import { useFormik } from "formik";
+import { useRouter } from "next/router";
+import { Fragment, useEffect, useRef } from "react";
+import { InferType } from "yup";
+import { ROUTES } from "../../data/routes.data";
+import { NewTeamSchema } from "../../data/schema";
+import { useUserData } from "../../utils/useUser";
+import { InlineErrorMessage } from "../forms/notification.component";
+
+export default function ManageTeamDialog({
+ open,
+ setOpen,
+ team,
+ onSuccess,
+ onCancel,
+}: {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ team?: ITeam;
+ onSuccess: () => void;
+ onCancel: () => void;
+}) {
+ const router = useRouter();
+ const cancelButtonRef = useRef(null);
+ const { supabase, user } = useUserData();
+
+ useEffect(() => {
+ if (open) {
+ formik.resetForm();
+ }
+
+ if (team) {
+ formik.setValues({
+ name: team.name,
+ image: team.image,
+ });
+ }
+ }, [open, team]);
+
+ const formik = useFormik>({
+ initialValues: {
+ name: "",
+ image: null,
+ },
+ validationSchema: NewTeamSchema,
+ onSubmit: async (values) => {
+ if (!user) {
+ router.push(ROUTES.LOGIN);
+ return;
+ }
+
+ if (!values.name) {
+ formik.setFieldError("name", "Name is required");
+ return;
+ }
+
+ if (team) {
+ await supabase
+ .from("teams")
+ .update({
+ name: values.name,
+ image: values.image,
+ })
+ .match({ id: team.id });
+ onSuccess();
+ } else {
+ await supabase
+ .from("teams")
+ .insert([
+ {
+ name: values.name,
+ image: values.image,
+ owner_id: user.id,
+ },
+ ])
+ .select();
+ onSuccess();
+ }
+
+ setOpen(false);
+ },
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/entity/empty-state.tsx b/apps/web/components/entity/empty-state.tsx
index aec78af..74ca515 100644
--- a/apps/web/components/entity/empty-state.tsx
+++ b/apps/web/components/entity/empty-state.tsx
@@ -16,6 +16,15 @@ export function EntityEmptyState({
buttonLabel,
disabled = false,
footer = null,
+ onButtonClick,
+}: {
+ title: string;
+ message: string;
+ buttonLink?: string;
+ buttonLabel?: string;
+ disabled?: boolean;
+ footer?: React.ReactNode;
+ onButtonClick?: () => void;
}) {
return (
@@ -38,7 +47,7 @@ export function EntityEmptyState({
{title}
{message}
- {!disabled && (
+ {!disabled && buttonLabel && buttonLink ? (
- )}
+ ) : null}
+ {onButtonClick && buttonLabel ? (
+
+
+
+ ) : null}
{footer}
);
diff --git a/apps/web/components/layout/header.component.tsx b/apps/web/components/layout/header.component.tsx
index 8660ada..39b35ba 100644
--- a/apps/web/components/layout/header.component.tsx
+++ b/apps/web/components/layout/header.component.tsx
@@ -27,6 +27,7 @@ export default function HeaderComponent() {
if (billingDetails?.has_active_subscription) {
return [
{ name: "Pages", href: ROUTES.PAGES },
+ { name: "Teams", href: ROUTES.TEAMS },
{ name: "Zapier", href: ROUTES.ZAPIER },
{ name: "Billing", href: ROUTES.BILLING },
{ name: "Support", href: ROUTES.SUPPORT, external: true },
diff --git a/apps/web/data/routes.data.ts b/apps/web/data/routes.data.ts
index 6a21fc5..798531d 100644
--- a/apps/web/data/routes.data.ts
+++ b/apps/web/data/routes.data.ts
@@ -12,6 +12,9 @@ export const ROUTES = {
PAGES: "/pages",
NEW_PAGE: "/pages/new",
+ // teams
+ TEAMS: "/teams",
+
// account
BILLING: "/account/billing",
diff --git a/apps/web/data/schema.ts b/apps/web/data/schema.ts
index 7d28b24..ff97989 100644
--- a/apps/web/data/schema.ts
+++ b/apps/web/data/schema.ts
@@ -39,4 +39,9 @@ export const NewPostSchema = object().shape({
allow_reactions: boolean(),
email_notified: boolean(),
notes: string().optional().nullable(),
-});
\ No newline at end of file
+});
+
+export const NewTeamSchema = object().shape({
+ name: string().required("Name is required").min(3, "Name must be at least 3 characters long"),
+ image: string().optional().nullable(),
+});
diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx
index 7e2f09d..2efebb2 100644
--- a/apps/web/pages/_app.tsx
+++ b/apps/web/pages/_app.tsx
@@ -3,9 +3,7 @@ import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { Analytics } from "@vercel/analytics/react";
import localFont from "next/font/local";
import Head from "next/head";
-import { useRouter } from "next/router";
-import Script from "next/script";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import "../styles/global.css";
import { UserContextProvider } from "../utils/useUser";
@@ -26,22 +24,6 @@ export default function App({ Component, pageProps }) {
const getLayout = Component.getLayout || ((page) => page);
const [supabaseClient] = useState(() => createPagesBrowserClient());
- const router = useRouter();
- const googleTagId = "AW-11500375049";
-
- useEffect(() => {
- const handleRouteChange = (url: string) => {
- // @ts-ignore
- window.gtag("config", googleTagId, {
- page_path: url,
- });
- };
- router.events.on("routeChangeComplete", handleRouteChange);
- return () => {
- router.events.off("routeChangeComplete", handleRouteChange);
- };
- }, [router.events, googleTagId]);
-
return (
<>
@@ -55,24 +37,6 @@ export default function App({ Component, pageProps }) {
--geist-font: ${geist.style.fontFamily};
}
`}
-
-
{!pages.length && (
{page};
-Pages.getLayout = (page: JSX.Element) => {page};
diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx
new file mode 100644
index 0000000..494093b
--- /dev/null
+++ b/apps/web/pages/teams/index.tsx
@@ -0,0 +1,419 @@
+import { IPage } from "@changes-page/supabase/types/page";
+import { SpinnerWithSpacing } from "@changes-page/ui";
+import { CheckCircleIcon, UserIcon } from "@heroicons/react/solid";
+import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import {
+ PrimaryButton,
+ SecondaryButton,
+} from "../../components/core/buttons.component";
+import { notifyError } from "../../components/core/toast.component";
+import ConfirmDeleteDialog from "../../components/dialogs/confirm-delete-dialog.component";
+import ManageTeamDialog from "../../components/dialogs/manage-team-dialog.component";
+import { EntityEmptyState } from "../../components/entity/empty-state";
+import AuthLayout from "../../components/layout/auth-layout.component";
+import Page from "../../components/layout/page.component";
+import Changelog from "../../components/marketing/changelog";
+import { useUserData } from "../../utils/useUser";
+
+export default function Teams() {
+ const { billingDetails } = useUserData();
+ const router = useRouter();
+ const { supabase } = useUserData();
+
+ const [teams, setTeams] = useState([]);
+ const [showTeamModal, setShowTeamModal] = useState(false);
+ const [selectedTeam, setSelectedTeam] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [teamToDelete, setTeamToDelete] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const [pages, setPages] = useState([]);
+ const [showAssignPage, setShowAssignPage] = useState(null);
+ const [selectedPage, setSelectedPage] = useState(null);
+ const [assigningPage, setAssigningPage] = useState(false);
+
+ const fetchTeams = async () => {
+ setIsLoading(true);
+
+ const { data: teams, error: fetchError } = await supabase
+ .from("teams")
+ .select(
+ `
+ *,
+ pages (
+ id,
+ title,
+ created_at
+ ),
+ team_members (
+ id,
+ user_id,
+ role
+ ),
+ team_invitations (
+ id,
+ email,
+ role,
+ created_at
+ )
+ `
+ )
+ .order("created_at", { ascending: false });
+
+ const { data: pages, error: pagesError } = await supabase
+ .from("pages")
+ .select("*");
+
+ console.log(teams);
+ setTeams(teams ?? []);
+ setPages(pages ?? []);
+
+ if (fetchError) {
+ notifyError("Failed to fetch teams");
+ }
+
+ if (pagesError) {
+ notifyError("Failed to fetch pages");
+ }
+
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ fetchTeams();
+ }, []);
+
+ useHotkeys(
+ "n",
+ () => {
+ setSelectedTeam(null);
+ setShowTeamModal(true);
+ },
+ [router]
+ );
+
+ const deleteTeam = async (teamId: string) => {
+ setIsDeleting(true);
+ const { error } = await supabase
+ .from("teams")
+ .delete()
+ .match({ id: teamId });
+
+ if (error) {
+ notifyError("Failed to delete team");
+ return;
+ }
+
+ fetchTeams();
+ setTeamToDelete(null);
+ setIsDeleting(false);
+ };
+
+ return (
+ <>
+ setShowTeamModal(true)}
+ disabled={!billingDetails?.has_active_subscription}
+ />
+ }
+ >
+ {billingDetails?.has_active_subscription ? : null}
+
+
+ {isLoading ? (
+
+ ) : !teams.length ? (
+
setShowTeamModal(true)}
+ />
+ ) : (
+
+ {teams.map((team) => (
+
+
+
+ {team.image ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {team.name}
+
+
+
+
+
+
+
+
+
+
+
+
-
+ Pages
+
+
-
+
+ {team.pages?.length > 0 ? (
+
+ {team.pages?.map(
+ (page: Pick) => (
+ -
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+ )
+ )}
+
+ ) : (
+
+ No pages yet, add one to share with your team.
+
+ Once shared, all team members will have complete
+ access to the page.
+
+ )}
+
+
+ {showAssignPage === team.id ? (
+
+
+
+
{
+ if (!selectedPage) {
+ return;
+ }
+
+ setAssigningPage(true);
+
+ await supabase
+ .from("pages")
+ .update({
+ team_id: team.id,
+ })
+ .match({ id: selectedPage });
+
+ fetchTeams();
+ setAssigningPage(null);
+ setShowAssignPage(null);
+ setSelectedPage(null);
+ }}
+ />
+
+ setShowAssignPage(null)}
+ />
+
+ ) : (
+
setShowAssignPage(team.id)}
+ />
+ )}
+
+
+
+
+
-
+ Invites
+
+
-
+
+
+
+
-
+ Members
+
+ {team.team_members?.length > 0 ? (
+
-
+
+ {team.team_members?.map(
+ (member: {
+ id: string;
+ user_id: string;
+ role: string;
+ }) => (
+ -
+
+
+
+
+ {member.user_id}
+
+
+ {member.role}
+
+
+
+
+
+ )
+ )}
+
+
+ ) : (
+
-
+ No members yet
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {
+ fetchTeams();
+ setShowTeamModal(false);
+ setSelectedTeam(null);
+ }}
+ onCancel={() => {
+ setSelectedTeam(null);
+ }}
+ />
+
+ setTeamToDelete(null)}
+ deleteCallback={() => deleteTeam(teamToDelete?.id)}
+ itemName={teamToDelete?.name}
+ highRiskAction
+ riskVerificationText={teamToDelete?.name}
+ processing={isDeleting}
+ />
+ >
+ );
+}
+
+Teams.getLayout = (page: JSX.Element) => {page};
diff --git a/apps/web/utils/useDatabase.ts b/apps/web/utils/useDatabase.ts
index babb1a2..a775484 100644
--- a/apps/web/utils/useDatabase.ts
+++ b/apps/web/utils/useDatabase.ts
@@ -214,7 +214,7 @@ export const createOrRetrievePageSettings = async (
.from("page_settings")
.select("*")
.eq("page_id", page_id)
- .eq("user_id", user_id)
+ // .eq("user_id", user_id)
.single();
if (pageSettings) return pageSettings;
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
new file mode 100644
index 0000000..23ea0fb
--- /dev/null
+++ b/packages/supabase/migrations/16_teams.sql
@@ -0,0 +1,71 @@
+create table teams (
+ id uuid default uuid_generate_v4() primary key,
+ owner_id uuid references users not null,
+ name text not null,
+ image text,
+ metadata jsonb,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table teams add constraint name_length check (length(name) >= 3);
+
+alter table teams enable row level security;
+create policy "Can insert teams." on teams for insert with check (auth.uid() = owner_id);
+create policy "Can view own teams." on teams for select using (auth.uid() = owner_id);
+create policy "Can update own teams." on teams for update using (auth.uid() = owner_id);
+create policy "Can delete own teams." on teams for delete using (auth.uid() = owner_id);
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON teams
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+create table team_members (
+ id uuid default uuid_generate_v4() primary key,
+ team_id uuid references teams not null,
+ user_id uuid references users not null,
+ role text not null,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table team_members enable row level security;
+create policy "Owner can view own team members." on team_members for select using (team_id in (select id from teams where owner_id = auth.uid()));
+create policy "Owner can delete own team members." on team_members for delete using (team_id in (select id from teams where owner_id = auth.uid()));
+create policy "Team members can view their membership." on team_members for select using (user_id = auth.uid());
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON team_members
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+create table team_invitations (
+ id uuid default uuid_generate_v4() primary key,
+ team_id uuid references teams not null,
+ inviter_id uuid references users not null,
+ email text not null,
+ role text not null,
+ status text not null,
+ expires_at timestamp with time zone not null default timezone('utc'::text, now() + interval '1 day'),
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table team_invitations enable row level security;
+create policy "Can insert team invitations." on team_invitations for insert with check (auth.uid() = inviter_id);
+create policy "Can view own team invitations." on team_invitations for select using (auth.uid() = inviter_id);
+create policy "Can delete own team invitations." on team_invitations for delete using (auth.uid() = inviter_id);
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON team_invitations
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+-- Alter pages table to add team_id
+alter table pages add column team_id uuid references teams on delete set null;
+
+-- Teams members can view pages in their team and posts in those pages
+create policy "Can view pages in own team." on pages for select using (team_id in (select team_id from team_members where user_id = auth.uid()));
+create policy "Can view page_settings in own team." on page_settings for select using (page_id in (select id from pages));
+create policy "Can view posts in own team." on posts for select using (page_id in (select id from pages));
diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts
index 285dd59..1d6f1cd 100644
--- a/packages/supabase/types/index.ts
+++ b/packages/supabase/types/index.ts
@@ -203,6 +203,7 @@ export type Database = {
created_at: string
description: string | null
id: string
+ team_id: string | null
title: string
type: Database["public"]["Enums"]["page_type"]
updated_at: string
@@ -213,6 +214,7 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
+ team_id?: string | null
title: string
type: Database["public"]["Enums"]["page_type"]
updated_at?: string
@@ -223,13 +225,22 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
+ team_id?: string | null
title?: string
type?: Database["public"]["Enums"]["page_type"]
updated_at?: string
url_slug?: string | null
user_id?: string
}
- Relationships: []
+ Relationships: [
+ {
+ foreignKeyName: "pages_team_id_fkey"
+ columns: ["team_id"]
+ isOneToOne: false
+ referencedRelation: "teams"
+ referencedColumns: ["id"]
+ },
+ ]
}
post_reactions: {
Row: {
@@ -337,6 +348,137 @@ export type Database = {
},
]
}
+ team_invitations: {
+ Row: {
+ created_at: string
+ email: string
+ expires_at: string
+ id: string
+ inviter_id: string
+ role: string
+ status: string
+ team_id: string
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ email: string
+ expires_at?: string
+ id?: string
+ inviter_id: string
+ role: string
+ status: string
+ team_id: string
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ email?: string
+ expires_at?: string
+ id?: string
+ inviter_id?: string
+ role?: string
+ status?: string
+ team_id?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "team_invitations_inviter_id_fkey"
+ columns: ["inviter_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "team_invitations_team_id_fkey"
+ columns: ["team_id"]
+ isOneToOne: false
+ referencedRelation: "teams"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ team_members: {
+ Row: {
+ created_at: string
+ id: string
+ role: string
+ team_id: string
+ updated_at: string
+ user_id: string
+ }
+ Insert: {
+ created_at?: string
+ id?: string
+ role: string
+ team_id: string
+ updated_at?: string
+ user_id: string
+ }
+ Update: {
+ created_at?: string
+ id?: string
+ role?: string
+ team_id?: string
+ updated_at?: string
+ user_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "team_members_team_id_fkey"
+ columns: ["team_id"]
+ isOneToOne: false
+ referencedRelation: "teams"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "team_members_user_id_fkey"
+ columns: ["user_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ teams: {
+ Row: {
+ created_at: string
+ id: string
+ image: string | null
+ metadata: Json | null
+ name: string
+ owner_id: string
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ id?: string
+ image?: string | null
+ metadata?: Json | null
+ name: string
+ owner_id: string
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ id?: string
+ image?: string | null
+ metadata?: Json | null
+ name?: string
+ owner_id?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "teams_owner_id_fkey"
+ columns: ["owner_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
users: {
Row: {
avatar_url: string | null
diff --git a/packages/supabase/types/page.ts b/packages/supabase/types/page.ts
index 1c38b3b..d4db597 100644
--- a/packages/supabase/types/page.ts
+++ b/packages/supabase/types/page.ts
@@ -7,6 +7,10 @@ export type IPost = Database["public"]["Tables"]["posts"]["Row"];
export type IPageEmailSubscriber =
Database["public"]["Tables"]["page_email_subscribers"]["Row"];
export type IPageView = Database["public"]["Tables"]["page_views"]["Row"];
+export type ITeam = Database["public"]["Tables"]["teams"]["Row"];
+export type ITeamMember = Database["public"]["Tables"]["team_members"]["Row"];
+export type ITeamInvitation =
+ Database["public"]["Tables"]["team_invitations"]["Row"];
export enum PageType {
changelogs = "changelogs",
From 9828f15c4cceda41e6a2b62b08930df00789b78e Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 10:01:28 +1100
Subject: [PATCH 02/24] Refactor page settings to support team-based access and
remove user_id dependency
---
.../integrations/zapier/trigger-new-post.tsx | 5 +---
apps/web/pages/api/pages/settings/index.ts | 4 +--
apps/web/pages/api/pages/webhook.ts | 4 +--
apps/web/pages/api/posts/webhook.ts | 4 +--
apps/web/pages/pages/[page_id]/[post_id].tsx | 4 +--
apps/web/pages/pages/[page_id]/index.tsx | 4 +--
apps/web/pages/pages/[page_id]/new.tsx | 4 +--
.../pages/[page_id]/settings/[activeTab].tsx | 4 +--
apps/web/pages/pages/index.tsx | 25 +++++++++++--------
apps/web/utils/useDatabase.ts | 5 ++--
packages/supabase/migrations/16_teams.sql | 14 +++++++++--
packages/supabase/types/index.ts | 3 ---
12 files changed, 42 insertions(+), 38 deletions(-)
diff --git a/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx b/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx
index 7a1df67..2d17308 100644
--- a/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx
+++ b/apps/web/pages/api/integrations/zapier/trigger-new-post.tsx
@@ -44,10 +44,7 @@ export default async function handler(
.order("created_at", { ascending: false })
.range(offset, limit - 1 + offset);
- const pageSettings = await createOrRetrievePageSettings(
- pageDetails.user_id,
- pageDetails.id
- );
+ const pageSettings = await createOrRetrievePageSettings(pageDetails.id);
const postsWithUrl = (posts ?? []).map((post: IPost) => ({
...post,
diff --git a/apps/web/pages/api/pages/settings/index.ts b/apps/web/pages/api/pages/settings/index.ts
index c1fb9a9..72ba978 100644
--- a/apps/web/pages/api/pages/settings/index.ts
+++ b/apps/web/pages/api/pages/settings/index.ts
@@ -1,6 +1,6 @@
-import type { NextApiRequest, NextApiResponse } from "next";
import { IErrorResponse } from "@changes-page/supabase/types/api";
import { IPageSettings } from "@changes-page/supabase/types/page";
+import type { NextApiRequest, NextApiResponse } from "next";
import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin";
import { createOrRetrievePageSettings } from "../../../../utils/useDatabase";
@@ -16,7 +16,7 @@ const getPageSettings = async (
console.log("getPageSettings", user?.id);
- const data = await createOrRetrievePageSettings(user.id, String(page_id));
+ const data = await createOrRetrievePageSettings(String(page_id));
return res.status(200).json(data);
} catch (err) {
diff --git a/apps/web/pages/api/pages/webhook.ts b/apps/web/pages/api/pages/webhook.ts
index 7fd3a44..3ea9aad 100644
--- a/apps/web/pages/api/pages/webhook.ts
+++ b/apps/web/pages/api/pages/webhook.ts
@@ -1,6 +1,6 @@
+import { IPage } from "@changes-page/supabase/types/page";
import { NextApiRequest, NextApiResponse } from "next";
import { v4 } from "uuid";
-import { IPage } from "@changes-page/supabase/types/page";
import { revalidatePage } from "../../../utils/revalidate";
import {
createOrRetrievePageSettings,
@@ -30,7 +30,7 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
await revalidatePage(page.url_slug);
try {
- const settings = await createOrRetrievePageSettings(user_id, page.id);
+ const settings = await createOrRetrievePageSettings(page.id);
if (settings?.custom_domain) {
await revalidatePage(settings.custom_domain);
}
diff --git a/apps/web/pages/api/posts/webhook.ts b/apps/web/pages/api/posts/webhook.ts
index df7e290..7b578a9 100644
--- a/apps/web/pages/api/posts/webhook.ts
+++ b/apps/web/pages/api/posts/webhook.ts
@@ -39,7 +39,7 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const page = await getPageById(page_id);
- const settings = await createOrRetrievePageSettings(user_id, page_id);
+ const settings = await createOrRetrievePageSettings(page_id);
// Revalidate
await revalidatePage(page.url_slug);
if (settings?.custom_domain) {
@@ -56,7 +56,7 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
}
const page = await getPageById(page_id);
- const settings = await createOrRetrievePageSettings(user_id, page_id);
+ const settings = await createOrRetrievePageSettings(page_id);
/**
* BE VERY CAREFUL, THIS IS A VERY IMPORTANT LOGIC
diff --git a/apps/web/pages/pages/[page_id]/[post_id].tsx b/apps/web/pages/pages/[page_id]/[post_id].tsx
index d03bfac..b65eaf2 100644
--- a/apps/web/pages/pages/[page_id]/[post_id].tsx
+++ b/apps/web/pages/pages/[page_id]/[post_id].tsx
@@ -18,8 +18,8 @@ import { useUserData } from "../../../utils/useUser";
export async function getServerSideProps({ params, req, res }) {
const { page_id, post_id } = params;
- const { supabase, user } = await getSupabaseServerClient({ req, res });
- const settings = await createOrRetrievePageSettings(user.id, String(page_id));
+ const { supabase } = await getSupabaseServerClient({ req, res });
+ const settings = await createOrRetrievePageSettings(String(page_id));
const { data: post } = await supabase
.from("posts")
.select("*")
diff --git a/apps/web/pages/pages/[page_id]/index.tsx b/apps/web/pages/pages/[page_id]/index.tsx
index 6c36068..8d8e36a 100644
--- a/apps/web/pages/pages/[page_id]/index.tsx
+++ b/apps/web/pages/pages/[page_id]/index.tsx
@@ -49,7 +49,7 @@ import { useUserData } from "../../../utils/useUser";
export async function getServerSideProps({ req, res, params }) {
const { page_id } = params;
- const { user, supabase } = await getSupabaseServerClient({ req, res });
+ const { supabase } = await getSupabaseServerClient({ req, res });
const page = await getPage(supabase, page_id).catch((e) => {
console.error("Failed to get page", e);
return null;
@@ -61,7 +61,7 @@ export async function getServerSideProps({ req, res, params }) {
};
}
- const settings = await createOrRetrievePageSettings(user.id, String(page_id));
+ const settings = await createOrRetrievePageSettings(String(page_id));
return {
props: {
diff --git a/apps/web/pages/pages/[page_id]/new.tsx b/apps/web/pages/pages/[page_id]/new.tsx
index fecb9c5..719eb97 100644
--- a/apps/web/pages/pages/[page_id]/new.tsx
+++ b/apps/web/pages/pages/[page_id]/new.tsx
@@ -13,14 +13,12 @@ import { ROUTES } from "../../../data/routes.data";
import { NewPostSchema } from "../../../data/schema";
import { track } from "../../../utils/analytics";
import { httpPost } from "../../../utils/http";
-import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin";
import { createOrRetrievePageSettings } from "../../../utils/useDatabase";
export async function getServerSideProps({ params, req, res }) {
const { page_id } = params;
- const { user } = await getSupabaseServerClient({ req, res });
- const settings = await createOrRetrievePageSettings(user.id, String(page_id));
+ const settings = await createOrRetrievePageSettings(String(page_id));
return {
props: {
diff --git a/apps/web/pages/pages/[page_id]/settings/[activeTab].tsx b/apps/web/pages/pages/[page_id]/settings/[activeTab].tsx
index abe7c67..0e4cb0a 100644
--- a/apps/web/pages/pages/[page_id]/settings/[activeTab].tsx
+++ b/apps/web/pages/pages/[page_id]/settings/[activeTab].tsx
@@ -24,9 +24,9 @@ const IntegrationsSettings = dynamic(
export async function getServerSideProps({ req, res, params }) {
const { page_id } = params;
- const { user, supabase } = await getSupabaseServerClient({ req, res });
+ const { supabase } = await getSupabaseServerClient({ req, res });
const page = await getPage(supabase, page_id);
- const settings = await createOrRetrievePageSettings(user.id, String(page_id));
+ const settings = await createOrRetrievePageSettings(String(page_id));
return {
props: {
diff --git a/apps/web/pages/pages/index.tsx b/apps/web/pages/pages/index.tsx
index c8d3c3b..561b57d 100644
--- a/apps/web/pages/pages/index.tsx
+++ b/apps/web/pages/pages/index.tsx
@@ -1,5 +1,5 @@
import { PageType, PageTypeToLabel } from "@changes-page/supabase/types/page";
-import { PlusIcon } from "@heroicons/react/solid";
+import { PlusIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { useRouter } from "next/router";
@@ -36,7 +36,7 @@ export default function Pages({
pages,
error,
}: InferGetServerSidePropsType) {
- const { billingDetails } = useUserData();
+ const { billingDetails, user } = useUserData();
const router = useRouter();
useHotkeys("n", () => router.push(ROUTES.NEW_PAGE), [router]);
@@ -144,17 +144,20 @@ export default function Pages({
-
+ {page.user_id !== user?.id ? (
+
+
+ Editor
+
+ ) : (
+
+
+ Owner
+
+ )}
))}
diff --git a/apps/web/utils/useDatabase.ts b/apps/web/utils/useDatabase.ts
index a775484..b1646da 100644
--- a/apps/web/utils/useDatabase.ts
+++ b/apps/web/utils/useDatabase.ts
@@ -207,14 +207,12 @@ export const updateSubscriptionUsage = async (
};
export const createOrRetrievePageSettings = async (
- user_id: string,
page_id: string
) => {
const { data: pageSettings } = await supabaseAdmin
.from("page_settings")
.select("*")
.eq("page_id", page_id)
- // .eq("user_id", user_id)
.single();
if (pageSettings) return pageSettings;
@@ -222,7 +220,7 @@ export const createOrRetrievePageSettings = async (
const { data: newPageSettings, error: createPageSettingsError } =
await supabaseAdmin
.from("page_settings")
- .insert([{ user_id, page_id }])
+ .insert([{ page_id }])
.select();
if (createPageSettingsError) throw createPageSettingsError;
@@ -376,3 +374,4 @@ export async function getPageAnalytics(
return data as unknown as IAnalyticsData[];
}
+
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
index 23ea0fb..25ea774 100644
--- a/packages/supabase/migrations/16_teams.sql
+++ b/packages/supabase/migrations/16_teams.sql
@@ -67,5 +67,15 @@ alter table pages add column team_id uuid references teams on delete set null;
-- Teams members can view pages in their team and posts in those pages
create policy "Can view pages in own team." on pages for select using (team_id in (select team_id from team_members where user_id = auth.uid()));
-create policy "Can view page_settings in own team." on page_settings for select using (page_id in (select id from pages));
-create policy "Can view posts in own team." on posts for select using (page_id in (select id from pages));
+
+-- Update page_settings
+alter policy "Can view own page_settings." on page_settings using (page_id in (select id from pages));
+alter policy "Can update own page_settings." on page_settings using (page_id in (select id from pages));
+drop policy "Can delete own page_settings." on page_settings;
+
+alter table page_settings drop column user_id;
+
+-- Update posts
+alter policy "Can view own posts." on posts using (page_id in (select id from pages));
+alter policy "Can update own posts." on posts using (page_id in (select id from pages));
+alter policy "Can delete own posts." on posts using (page_id in (select id from pages));
\ No newline at end of file
diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts
index 1d6f1cd..afeb7d3 100644
--- a/packages/supabase/types/index.ts
+++ b/packages/supabase/types/index.ts
@@ -78,7 +78,6 @@ export type Database = {
tiktok_url: string | null
twitter_url: string | null
updated_at: string
- user_id: string
whitelabel: boolean
youtube_url: string | null
}
@@ -109,7 +108,6 @@ export type Database = {
tiktok_url?: string | null
twitter_url?: string | null
updated_at?: string
- user_id: string
whitelabel?: boolean
youtube_url?: string | null
}
@@ -140,7 +138,6 @@ export type Database = {
tiktok_url?: string | null
twitter_url?: string | null
updated_at?: string
- user_id?: string
whitelabel?: boolean
youtube_url?: string | null
}
From 294fa3337bcaab9401afefc36c972747d72569c2 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 10:48:20 +1100
Subject: [PATCH 03/24] Enhance team management with owner and member views,
page sharing, and team member interactions
---
apps/web/pages/pages/index.tsx | 15 +-
apps/web/pages/teams/index.tsx | 475 ++++++++++++----------
packages/supabase/migrations/16_teams.sql | 16 +-
3 files changed, 294 insertions(+), 212 deletions(-)
diff --git a/apps/web/pages/pages/index.tsx b/apps/web/pages/pages/index.tsx
index 561b57d..9c4d060 100644
--- a/apps/web/pages/pages/index.tsx
+++ b/apps/web/pages/pages/index.tsx
@@ -21,7 +21,14 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { data: pages, error } = await supabase
.from("pages")
- .select("*")
+ .select(
+ `*,
+ teams (
+ id,
+ name
+ )
+ `
+ )
.order("updated_at", { ascending: false });
return {
@@ -147,10 +154,12 @@ export default function Pages({
className="pointer-events-none absolute top-6 right-6 text-gray-500 dark:text-gray-400 group-hover:text-indigo-400"
aria-hidden="true"
>
- {page.user_id !== user?.id ? (
+ {page.teams && page.user_id !== user?.id ? (
- Editor
+
+ Editor ({page.teams.name})
+
) : (
diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx
index 494093b..835e8a5 100644
--- a/apps/web/pages/teams/index.tsx
+++ b/apps/web/pages/teams/index.tsx
@@ -20,7 +20,7 @@ import { useUserData } from "../../utils/useUser";
export default function Teams() {
const { billingDetails } = useUserData();
const router = useRouter();
- const { supabase } = useUserData();
+ const { user, supabase } = useUserData();
const [teams, setTeams] = useState([]);
const [showTeamModal, setShowTeamModal] = useState(false);
@@ -143,247 +143,306 @@ export default function Teams() {
/>
) : (
- {teams.map((team) => (
-
-
-
- {team.image ? (
-

- ) : (
-
-
-
- )}
-
-
- {team.name}
-
-
-
-
+ {teams.map((team) =>
+ team.owner_id === user?.id ? (
+
+
+
+ {team.image ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ {team.name}
+
+
+
+
+
-
-
-
-
-
-
- Pages
-
-
-
-
- {team.pages?.length > 0 ? (
+
+
+
+
-
+ Pages
+
+
-
+
+ {team.pages?.length > 0 ? (
+
+ {team.pages?.map(
+ (page: Pick) => (
+ -
+
+
+
+
+ {page.title}
+
+
+
+
+
+
+
+ )
+ )}
+
+ ) : (
+
+ No pages yet, add one to share with your team.
+
+ Once shared, all team members will have
+ complete access to the page.
+
+ )}
+
+
+ {showAssignPage === team.id ? (
+
+
+
+
{
+ if (!selectedPage) {
+ return;
+ }
+
+ setAssigningPage(true);
+
+ await supabase
+ .from("pages")
+ .update({
+ team_id: team.id,
+ })
+ .match({ id: selectedPage });
+
+ fetchTeams();
+ setAssigningPage(null);
+ setShowAssignPage(null);
+ setSelectedPage(null);
+ }}
+ />
+
+ setShowAssignPage(null)}
+ />
+
+ ) : (
+
setShowAssignPage(team.id)}
+ />
+ )}
+
+
+
+
+
-
+ Members
+
+ {team.team_members?.length > 0 ? (
+
-
- {team.pages?.map(
- (page: Pick) => (
+ {team.team_members?.map(
+ (member: {
+ id: string;
+ user_id: string;
+ role: string;
+ }) => (
-
-
- {page.title}
+ {member.user_id}
+
+
+ {member.role}
-
+
)
)}
- ) : (
-
- No pages yet, add one to share with your team.
-
- Once shared, all team members will have complete
- access to the page.
-
- )}
+
+ ) : (
+
-
+ No members yet
+
+ )}
-
- {showAssignPage === team.id ? (
-
-
-
-
{
- if (!selectedPage) {
- return;
- }
-
- setAssigningPage(true);
-
- await supabase
- .from("pages")
- .update({
- team_id: team.id,
- })
- .match({ id: selectedPage });
-
- fetchTeams();
- setAssigningPage(null);
- setShowAssignPage(null);
- setSelectedPage(null);
- }}
- />
-
- setShowAssignPage(null)}
- />
-
- ) : (
-
setShowAssignPage(team.id)}
- />
- )}
-
-
-
-
- -
- Invites
-
- -
-
-
-
- -
- Members
-
- {team.team_members?.length > 0 ? (
+ -
+ Invites
+
-
-
- {team.team_members?.map(
- (member: {
- id: string;
- user_id: string;
- role: string;
- }) => (
- -
-
-
-
-
- {member.user_id}
-
-
- {member.role}
-
-
-
-
-
- )
- )}
-
+
+
+
+
+
+ ) : (
+
+
+
+ {team.image ? (
+

) : (
-
-
- No members yet
-
+
+
+
)}
+
+
+ {team.name} (
+ {team.team_members?.[0]?.role?.toUpperCase()})
+
+
+
+
+
-
+
-
- ))}
+ )
+ )}
)}
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
index 25ea774..7a5584f 100644
--- a/packages/supabase/migrations/16_teams.sql
+++ b/packages/supabase/migrations/16_teams.sql
@@ -1,3 +1,4 @@
+-- TEAMS table
create table teams (
id uuid default uuid_generate_v4() primary key,
owner_id uuid references users not null,
@@ -21,6 +22,7 @@ BEFORE UPDATE ON teams
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
+-- TEAM MEMBERS table
create table team_members (
id uuid default uuid_generate_v4() primary key,
team_id uuid references teams not null,
@@ -33,13 +35,14 @@ create table team_members (
alter table team_members enable row level security;
create policy "Owner can view own team members." on team_members for select using (team_id in (select id from teams where owner_id = auth.uid()));
create policy "Owner can delete own team members." on team_members for delete using (team_id in (select id from teams where owner_id = auth.uid()));
-create policy "Team members can view their membership." on team_members for select using (user_id = auth.uid());
+create policy "Team members can leave their team." on team_members for delete using (user_id = auth.uid());
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON team_members
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
+-- TEAM INVITATIONS table
create table team_invitations (
id uuid default uuid_generate_v4() primary key,
team_id uuid references teams not null,
@@ -62,6 +65,17 @@ BEFORE UPDATE ON team_invitations
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
+-- Helper function to check if a user is a member of a team
+create or replace function is_team_member(tid uuid, uid uuid) returns boolean as $$
+begin
+ return exists (select 1 from team_members where team_id = tid and user_id = uid);
+end;
+$$ language plpgsql security definer;
+
+-- Policies using the helper function
+create policy "Team members can view their membership." on team_members for select using (is_team_member(team_id, auth.uid()));
+create policy "Members can view teams." on teams for select using (is_team_member(id, auth.uid()));
+
-- Alter pages table to add team_id
alter table pages add column team_id uuid references teams on delete set null;
From 1c2e044cb29c7047778a2e72c53a0ddc025285a0 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 11:41:06 +1100
Subject: [PATCH 04/24] Implement team invitation with email notifications
---
.../components/layout/header.component.tsx | 22 ++---
apps/web/inngest/email/send-team-invite.ts | 25 ++++++
apps/web/pages/api/inngest.ts | 2 +
apps/web/pages/api/teams/invite/index.ts | 77 ++++++++++++++++++
apps/web/pages/teams/index.tsx | 80 +++++++++++++++++++
packages/supabase/migrations/16_teams.sql | 3 +-
6 files changed, 193 insertions(+), 16 deletions(-)
create mode 100644 apps/web/inngest/email/send-team-invite.ts
create mode 100644 apps/web/pages/api/teams/invite/index.ts
diff --git a/apps/web/components/layout/header.component.tsx b/apps/web/components/layout/header.component.tsx
index 39b35ba..33c076f 100644
--- a/apps/web/components/layout/header.component.tsx
+++ b/apps/web/components/layout/header.component.tsx
@@ -24,21 +24,13 @@ export default function HeaderComponent() {
const navigation = useMemo(() => {
if (user) {
- if (billingDetails?.has_active_subscription) {
- return [
- { name: "Pages", href: ROUTES.PAGES },
- { name: "Teams", href: ROUTES.TEAMS },
- { name: "Zapier", href: ROUTES.ZAPIER },
- { name: "Billing", href: ROUTES.BILLING },
- { name: "Support", href: ROUTES.SUPPORT, external: true },
- ];
- } else {
- return [
- { name: "Pages", href: ROUTES.PAGES },
- { name: "Pricing", href: ROUTES.PRICING },
- { name: "Support", href: ROUTES.SUPPORT, external: true },
- ];
- }
+ return [
+ { name: "Pages", href: ROUTES.PAGES },
+ { name: "Teams", href: ROUTES.TEAMS },
+ { name: "Zapier", href: ROUTES.ZAPIER },
+ { name: "Billing", href: ROUTES.BILLING },
+ { name: "Support", href: ROUTES.SUPPORT, external: true },
+ ];
}
return [
diff --git a/apps/web/inngest/email/send-team-invite.ts b/apps/web/inngest/email/send-team-invite.ts
new file mode 100644
index 0000000..f4f8dce
--- /dev/null
+++ b/apps/web/inngest/email/send-team-invite.ts
@@ -0,0 +1,25 @@
+import inngestClient from "../../utils/inngest";
+import postmarkClient from "../../utils/postmark";
+
+export const sendTeamInviteEmail = inngestClient.createFunction(
+ { name: "Email: Team Invite" },
+ { event: "email/team.invite" },
+ async ({ event }) => {
+ const { email, payload } = event.data;
+
+ console.log("Job started", {
+ email,
+ payload,
+ });
+
+ const result = await postmarkClient.sendEmailWithTemplate({
+ MessageStream: "outbound",
+ From: "hello@changes.page",
+ To: email,
+ TemplateAlias: "team-invite",
+ TemplateModel: payload,
+ });
+
+ return { body: "Job completed", result };
+ }
+);
diff --git a/apps/web/pages/api/inngest.ts b/apps/web/pages/api/inngest.ts
index a7b92e7..70f6dac 100644
--- a/apps/web/pages/api/inngest.ts
+++ b/apps/web/pages/api/inngest.ts
@@ -2,6 +2,7 @@ import { serve } from "inngest/next";
import { handleSubscriptionChange } from "../../inngest/billing/handle-subscription";
import { reportUsageForStripeInvoice } from "../../inngest/billing/report-pages-usage-invoice";
import { sendConfirmEmailNotification } from "../../inngest/email/send-confirm-email-notification";
+import { sendTeamInviteEmail } from "../../inngest/email/send-team-invite";
import { sendWelcomeEmail } from "../../inngest/email/send-welcome-email";
import { deleteImagesJob } from "../../inngest/jobs/delete-images";
import { sendPostNotification } from "./../../inngest/email/send-post-notification";
@@ -15,6 +16,7 @@ export default serve("changes-page", [
sendConfirmEmailNotification,
sendPostNotification,
sendWelcomeEmail,
+ sendTeamInviteEmail,
// Background Jobs
deleteImagesJob,
]);
diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts
new file mode 100644
index 0000000..a920e29
--- /dev/null
+++ b/apps/web/pages/api/teams/invite/index.ts
@@ -0,0 +1,77 @@
+import { supabaseAdmin } from "@changes-page/supabase/admin";
+import { NextApiRequest, NextApiResponse } from "next";
+import inngestClient from "../../../../utils/inngest";
+import { apiRateLimiter } from "../../../../utils/rate-limit";
+import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin";
+import { getUserById } from "../../../../utils/useDatabase";
+
+const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === "POST") {
+ const { team_id, email } = req.body;
+
+ try {
+ await apiRateLimiter(req, res);
+
+ const { user, supabase } = await getSupabaseServerClient({ req, res });
+
+ const { has_active_subscription } = await getUserById(user.id);
+ if (!has_active_subscription) {
+ return res.status(403).json({
+ error: { statusCode: 403, message: "Missing subscription" },
+ });
+ }
+
+ const { data: team } = await supabase
+ .from("teams")
+ .select("*")
+ .eq("id", team_id)
+ .eq("owner_id", user.id)
+ .single();
+
+ if (!team) {
+ return res.status(403).json({
+ error: { statusCode: 403, message: "Team not found" },
+ });
+ }
+
+ const { data: invitation } = await supabaseAdmin
+ .from("team_invitations")
+ .insert({
+ team_id,
+ inviter_id: user.id,
+ email,
+ role: "editor",
+ status: "pending",
+ })
+ .select()
+ .single();
+
+ if (!invitation) {
+ return res.status(500).json({
+ error: { statusCode: 500, message: "Failed to create invitation" },
+ });
+ }
+
+ await inngestClient.send({
+ name: "email/team.invite",
+ data: {
+ owner_name: user.user_metadata?.name ?? user.email,
+ team_name: team.name,
+ confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/teams/invite/join/${invitation.id}`,
+ },
+ });
+
+ return res.status(201).json({ ok: true });
+ } catch (err) {
+ console.log("inviteUser", err);
+ res
+ .status(500)
+ .json({ error: { statusCode: 500, message: err.message } });
+ }
+ } else {
+ res.setHeader("Allow", "POST,PUT");
+ res.status(405).end("Method Not Allowed");
+ }
+};
+
+export default inviteUser;
diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx
index 835e8a5..4bd9e7c 100644
--- a/apps/web/pages/teams/index.tsx
+++ b/apps/web/pages/teams/index.tsx
@@ -3,6 +3,7 @@ import { SpinnerWithSpacing } from "@changes-page/ui";
import { CheckCircleIcon, UserIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
import { useHotkeys } from "react-hotkeys-hook";
import {
PrimaryButton,
@@ -15,6 +16,7 @@ import { EntityEmptyState } from "../../components/entity/empty-state";
import AuthLayout from "../../components/layout/auth-layout.component";
import Page from "../../components/layout/page.component";
import Changelog from "../../components/marketing/changelog";
+import { httpPost } from "../../utils/http";
import { useUserData } from "../../utils/useUser";
export default function Teams() {
@@ -380,9 +382,87 @@ export default function Teams() {
Invites
+ {team.team_invitations?.length > 0 ? (
+
+ ) : null}
+
{
+ const email = prompt("Enter email address");
+ if (!email) {
+ return;
+ }
+
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ notifyError("Invalid email address");
+ return;
+ }
+
+ await toast.promise(
+ httpPost({
+ url: "/api/teams/invite",
+ data: {
+ team_id: team.id,
+ email,
+ },
+ }),
+ {
+ loading: "Sending invitation...",
+ success: "Invitation sent",
+ error: "Failed to send invitation",
+ }
+ );
+
+ fetchTeams();
+ }}
/>
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
index 7a5584f..221b88c 100644
--- a/packages/supabase/migrations/16_teams.sql
+++ b/packages/supabase/migrations/16_teams.sql
@@ -55,8 +55,9 @@ create table team_invitations (
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
+alter table team_invitations add constraint unique_email_team_id unique (email, team_id);
+
alter table team_invitations enable row level security;
-create policy "Can insert team invitations." on team_invitations for insert with check (auth.uid() = inviter_id);
create policy "Can view own team invitations." on team_invitations for select using (auth.uid() = inviter_id);
create policy "Can delete own team invitations." on team_invitations for delete using (auth.uid() = inviter_id);
From 92fa63ee3bac74ce160e97062f543f5744dc3dad Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 12:51:43 +1100
Subject: [PATCH 05/24] Add team invitation acceptance flow and update team
views
---
.../pages/api/teams/invite/accept/index.ts | 62 +++++++++
apps/web/pages/api/teams/invite/index.ts | 2 +-
apps/web/pages/teams/index.tsx | 128 +++++++++++-------
packages/supabase/migrations/16_teams.sql | 4 +
4 files changed, 146 insertions(+), 50 deletions(-)
create mode 100644 apps/web/pages/api/teams/invite/accept/index.ts
diff --git a/apps/web/pages/api/teams/invite/accept/index.ts b/apps/web/pages/api/teams/invite/accept/index.ts
new file mode 100644
index 0000000..9a76c22
--- /dev/null
+++ b/apps/web/pages/api/teams/invite/accept/index.ts
@@ -0,0 +1,62 @@
+import { supabaseAdmin } from "@changes-page/supabase/admin";
+import { NextApiRequest, NextApiResponse } from "next";
+import { apiRateLimiter } from "../../../../../utils/rate-limit";
+import { getSupabaseServerClient } from "../../../../../utils/supabase/supabase-admin";
+
+const acceptInvite = async (req: NextApiRequest, res: NextApiResponse) => {
+ if (req.method === "POST") {
+ const { invite_id } = req.body;
+
+ try {
+ await apiRateLimiter(req, res);
+
+ const { user, supabase } = await getSupabaseServerClient({ req, res });
+
+ const { data: invite } = await supabase
+ .from("team_invitations")
+ .select("*")
+ .eq("id", invite_id)
+ .eq("email", user.email)
+ .single();
+
+ if (!invite) {
+ return res.status(403).json({
+ error: { statusCode: 403, message: "Team not found" },
+ });
+ }
+
+ const { data: membership } = await supabaseAdmin
+ .from("team_members")
+ .insert({
+ team_id: invite.team_id,
+ user_id: user.id,
+ role: invite.role,
+ })
+ .select()
+ .single();
+
+ if (!membership) {
+ return res.status(500).json({
+ error: { statusCode: 500, message: "Failed to create invitation" },
+ });
+ }
+
+ await supabaseAdmin
+ .from("team_invitations")
+ .delete()
+ .match({ id: invite_id });
+
+ return res.status(201).json({ ok: true });
+ } catch (err) {
+ console.log("inviteUser", err);
+ res
+ .status(500)
+ .json({ error: { statusCode: 500, message: err.message } });
+ }
+ } else {
+ res.setHeader("Allow", "POST,PUT");
+ res.status(405).end("Method Not Allowed");
+ }
+};
+
+export default acceptInvite;
diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts
index a920e29..c260eb0 100644
--- a/apps/web/pages/api/teams/invite/index.ts
+++ b/apps/web/pages/api/teams/invite/index.ts
@@ -57,7 +57,7 @@ const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
data: {
owner_name: user.user_metadata?.name ?? user.email,
team_name: team.name,
- confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/teams/invite/join/${invitation.id}`,
+ confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/teams`,
},
});
diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx
index 4bd9e7c..46951a2 100644
--- a/apps/web/pages/teams/index.tsx
+++ b/apps/web/pages/teams/index.tsx
@@ -39,43 +39,44 @@ export default function Teams() {
const fetchTeams = async () => {
setIsLoading(true);
- const { data: teams, error: fetchError } = await supabase
- .from("teams")
- .select(
+ const [
+ { data: teams, error: fetchError },
+ { data: pages, error: pagesError },
+ ] = await Promise.all([
+ supabase
+ .from("teams")
+ .select(
+ `
+ *,
+ pages (
+ id,
+ title,
+ created_at
+ ),
+ team_members (
+ id,
+ user_id,
+ role
+ ),
+ team_invitations (
+ id,
+ email,
+ role,
+ created_at
+ )
`
- *,
- pages (
- id,
- title,
- created_at
- ),
- team_members (
- id,
- user_id,
- role
- ),
- team_invitations (
- id,
- email,
- role,
- created_at
)
- `
- )
- .order("created_at", { ascending: false });
+ .order("created_at", { ascending: false }),
- const { data: pages, error: pagesError } = await supabase
- .from("pages")
- .select("*");
+ supabase.from("pages").select("*"),
+ ]);
- console.log(teams);
setTeams(teams ?? []);
setPages(pages ?? []);
if (fetchError) {
notifyError("Failed to fetch teams");
}
-
if (pagesError) {
notifyError("Failed to fetch pages");
}
@@ -490,32 +491,61 @@ export default function Teams() {
{team.name} (
- {team.team_members?.[0]?.role?.toUpperCase()})
+ {team.team_members?.[0]?.role
+ ? team.team_members?.[0]?.role?.toUpperCase()
+ : "Would you like to join?"}
+ )
-
+ ) : (
+ {
+ if (
+ confirm(
+ "Are you sure you want to leave this team?"
+ )
+ ) {
+ await supabase
+ .from("team_members")
+ .delete()
+ .match({
+ team_id: team.id,
+ user_id: user?.id,
+ });
+
+ fetchTeams();
+ }
+ }}
+ >
+ Leave
+
+ )}
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
index 221b88c..32ef6f7 100644
--- a/packages/supabase/migrations/16_teams.sql
+++ b/packages/supabase/migrations/16_teams.sql
@@ -16,6 +16,7 @@ create policy "Can insert teams." on teams for insert with check (auth.uid() = o
create policy "Can view own teams." on teams for select using (auth.uid() = owner_id);
create policy "Can update own teams." on teams for update using (auth.uid() = owner_id);
create policy "Can delete own teams." on teams for delete using (auth.uid() = owner_id);
+create policy "Invited users can view teams." on teams for select using (id in (select team_id from team_invitations where email = auth.email()));
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON teams
@@ -32,6 +33,8 @@ create table team_members (
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
+alter table team_members add constraint unique_team_user unique (team_id, user_id);
+
alter table team_members enable row level security;
create policy "Owner can view own team members." on team_members for select using (team_id in (select id from teams where owner_id = auth.uid()));
create policy "Owner can delete own team members." on team_members for delete using (team_id in (select id from teams where owner_id = auth.uid()));
@@ -60,6 +63,7 @@ alter table team_invitations add constraint unique_email_team_id unique (email,
alter table team_invitations enable row level security;
create policy "Can view own team invitations." on team_invitations for select using (auth.uid() = inviter_id);
create policy "Can delete own team invitations." on team_invitations for delete using (auth.uid() = inviter_id);
+create policy "Can view team invitations." on team_invitations for select using (auth.email() = email);
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON team_invitations
From a368f30d1d685aaaf3e5296576e5d17ac5ca2196 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 13:48:32 +1100
Subject: [PATCH 06/24] Remove subscription check for new posts
---
apps/web/pages/api/posts/index.ts | 9 +--------
apps/web/pages/api/teams/invite/index.ts | 3 ++-
apps/web/pages/pages/[page_id]/index.tsx | 2 --
3 files changed, 3 insertions(+), 11 deletions(-)
diff --git a/apps/web/pages/api/posts/index.ts b/apps/web/pages/api/posts/index.ts
index d598a7b..16463d8 100644
--- a/apps/web/pages/api/posts/index.ts
+++ b/apps/web/pages/api/posts/index.ts
@@ -3,7 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next";
import { NewPostSchema } from "../../../data/schema";
import { apiRateLimiter } from "../../../utils/rate-limit";
import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin";
-import { createPost, getUserById } from "../../../utils/useDatabase";
+import { createPost } from "../../../utils/useDatabase";
const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
@@ -26,13 +26,6 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => {
const { user } = await getSupabaseServerClient({ req, res });
- const { has_active_subscription } = await getUserById(user.id);
- if (!has_active_subscription) {
- return res.status(403).json({
- error: { statusCode: 403, message: "Missing subscription" },
- });
- }
-
const isValid = await NewPostSchema.isValid({
page_id,
title: title.trim(),
diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts
index c260eb0..3d7871c 100644
--- a/apps/web/pages/api/teams/invite/index.ts
+++ b/apps/web/pages/api/teams/invite/index.ts
@@ -1,5 +1,6 @@
import { supabaseAdmin } from "@changes-page/supabase/admin";
import { NextApiRequest, NextApiResponse } from "next";
+import { ROUTES } from "../../../../data/routes.data";
import inngestClient from "../../../../utils/inngest";
import { apiRateLimiter } from "../../../../utils/rate-limit";
import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin";
@@ -57,7 +58,7 @@ const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
data: {
owner_name: user.user_metadata?.name ?? user.email,
team_name: team.name,
- confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/teams`,
+ confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/${ROUTES.TEAMS}`,
},
});
diff --git a/apps/web/pages/pages/[page_id]/index.tsx b/apps/web/pages/pages/[page_id]/index.tsx
index 8d8e36a..542aa2a 100644
--- a/apps/web/pages/pages/[page_id]/index.tsx
+++ b/apps/web/pages/pages/[page_id]/index.tsx
@@ -78,7 +78,6 @@ export default function PageDetail({
settings: serverSettings,
}: InferGetServerSidePropsType) {
const router = useRouter();
- const { billingDetails } = useUserData();
const { supabase } = useUserData();
const { status } = router.query;
@@ -286,7 +285,6 @@ export default function PageDetail({
}
route={`/pages/${page_id}/new`}
keyboardShortcut={"N"}
- upgradeRequired={!billingDetails?.has_active_subscription}
/>
}
menuItems={
From c482668a642820f9b8bdc3303c597e66f9219158 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 14:00:36 +1100
Subject: [PATCH 07/24] feat: Improve team invitation API with input validation
and email payload
---
apps/web/pages/api/teams/invite/accept/index.ts | 5 +++++
apps/web/pages/api/teams/invite/index.ts | 15 ++++++++++++---
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/apps/web/pages/api/teams/invite/accept/index.ts b/apps/web/pages/api/teams/invite/accept/index.ts
index 9a76c22..969b1fc 100644
--- a/apps/web/pages/api/teams/invite/accept/index.ts
+++ b/apps/web/pages/api/teams/invite/accept/index.ts
@@ -6,6 +6,11 @@ import { getSupabaseServerClient } from "../../../../../utils/supabase/supabase-
const acceptInvite = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const { invite_id } = req.body;
+ if (!invite_id) {
+ return res.status(400).json({
+ error: { statusCode: 400, message: "Invalid request" },
+ });
+ }
try {
await apiRateLimiter(req, res);
diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts
index 3d7871c..3d37edf 100644
--- a/apps/web/pages/api/teams/invite/index.ts
+++ b/apps/web/pages/api/teams/invite/index.ts
@@ -1,6 +1,7 @@
import { supabaseAdmin } from "@changes-page/supabase/admin";
import { NextApiRequest, NextApiResponse } from "next";
import { ROUTES } from "../../../../data/routes.data";
+import { getAppBaseURL } from "../../../../utils/helpers";
import inngestClient from "../../../../utils/inngest";
import { apiRateLimiter } from "../../../../utils/rate-limit";
import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin";
@@ -9,6 +10,11 @@ import { getUserById } from "../../../../utils/useDatabase";
const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
const { team_id, email } = req.body;
+ if (!team_id || !email) {
+ return res.status(400).json({
+ error: { statusCode: 400, message: "Invalid request" },
+ });
+ }
try {
await apiRateLimiter(req, res);
@@ -56,9 +62,12 @@ const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
await inngestClient.send({
name: "email/team.invite",
data: {
- owner_name: user.user_metadata?.name ?? user.email,
- team_name: team.name,
- confirm_link: `${process.env.NEXT_PUBLIC_APP_URL}/${ROUTES.TEAMS}`,
+ email,
+ payload: {
+ owner_name: user.user_metadata?.name ?? user.email,
+ team_name: team.name,
+ confirm_link: `${getAppBaseURL()}/${ROUTES.TEAMS}`,
+ },
},
});
From 38c2af0bea0ee7541d36278fe4653f5a47379490 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 14:12:16 +1100
Subject: [PATCH 08/24] fix: Update middleware
---
apps/web/middleware.ts | 2 +-
apps/web/pages/api/teams/invite/index.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
index a3bd28f..9424aa5 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -22,5 +22,5 @@ export async function middleware(req: NextRequest) {
}
export const config = {
- matcher: ["/account", "/pages", "/pages/:path", "/account/billing"],
+ matcher: ["/account", "/pages", "/pages/:path", "/account/billing", "/teams"],
};
diff --git a/apps/web/pages/api/teams/invite/index.ts b/apps/web/pages/api/teams/invite/index.ts
index 3d37edf..d9a75f6 100644
--- a/apps/web/pages/api/teams/invite/index.ts
+++ b/apps/web/pages/api/teams/invite/index.ts
@@ -66,7 +66,7 @@ const inviteUser = async (req: NextApiRequest, res: NextApiResponse) => {
payload: {
owner_name: user.user_metadata?.name ?? user.email,
team_name: team.name,
- confirm_link: `${getAppBaseURL()}/${ROUTES.TEAMS}`,
+ confirm_link: `${getAppBaseURL()}${ROUTES.TEAMS}`,
},
},
});
From 28b53b68a257b1a5415e9b5931052796ada5d2fa Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 14:28:00 +1100
Subject: [PATCH 09/24] Remove unused revalidation logic
---
apps/page/README.md | 3 --
apps/page/pages/api/revalidate.ts | 37 --------------------
apps/web/README.md | 4 ---
apps/web/pages/api/pages/settings/webhook.ts | 18 +---------
apps/web/pages/api/pages/webhook.ts | 9 +----
apps/web/pages/api/posts/webhook.ts | 22 ------------
apps/web/utils/revalidate.ts | 17 ---------
7 files changed, 2 insertions(+), 108 deletions(-)
delete mode 100644 apps/page/pages/api/revalidate.ts
delete mode 100644 apps/web/utils/revalidate.ts
diff --git a/apps/page/README.md b/apps/page/README.md
index 8fa6d66..bdfa8c6 100644
--- a/apps/page/README.md
+++ b/apps/page/README.md
@@ -5,9 +5,6 @@ This folder contains the pages app which renders user changelog pages.
### Environment Variables
```
-# Revalidation
-REVALIDATE_TOKEN=
-
# Supabase details from https://app.supabase.io
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
diff --git a/apps/page/pages/api/revalidate.ts b/apps/page/pages/api/revalidate.ts
deleted file mode 100644
index c6c0e7e..0000000
--- a/apps/page/pages/api/revalidate.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { NextApiRequest, NextApiResponse } from "next";
-
-/**
- * @param req
- * @param res
- * @returns
- */
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse<{ revalidated: boolean } | { message: string } | string>
-) {
- return res.json({ revalidated: true });
- const { secret } = req.query;
- const { path } = req.body;
-
- // Check for secret to confirm this is a valid request
- if (secret !== process.env.REVALIDATE_TOKEN) {
- return res.status(401).json({ message: "Invalid token" });
- }
-
- if (!path) {
- return res.status(401).json({ message: "Invalid path" });
- }
-
- console.log("got revalidate request for", path);
-
- try {
- await res.revalidate(`/_sites/${path}/`);
-
- return res.json({ revalidated: true });
- } catch (err) {
- // If there was an error, Next.js will continue
- // to show the last successfully generated page
- console.log("[page] Error revalidating", err);
- return res.status(500).send("Error revalidating");
- }
-}
diff --git a/apps/web/README.md b/apps/web/README.md
index 22d5005..6a9b0d3 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -7,10 +7,6 @@ This folder contains the dashboard app for the project and all marketing pages.
```
NEXT_PUBLIC_PAGES_DOMAIN=http://localhost:3000
-# Revalidation
-REVALIDATE_ENDPOINT=
-REVALIDATE_TOKEN=
-
# Supabase details from https://app.supabase.io
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
diff --git a/apps/web/pages/api/pages/settings/webhook.ts b/apps/web/pages/api/pages/settings/webhook.ts
index 3d5af85..9661552 100644
--- a/apps/web/pages/api/pages/settings/webhook.ts
+++ b/apps/web/pages/api/pages/settings/webhook.ts
@@ -1,7 +1,5 @@
-import { NextApiRequest, NextApiResponse } from "next";
import { IPageSettings } from "@changes-page/supabase/types/page";
-import { revalidatePage } from "../../../../utils/revalidate";
-import { getPageById } from "../../../../utils/useDatabase";
+import { NextApiRequest, NextApiResponse } from "next";
const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
@@ -23,20 +21,6 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
page_id
);
- if (page_settings?.custom_domain) {
- await revalidatePage(page_settings.custom_domain);
- }
-
- try {
- const page = await getPageById(page_id);
- await revalidatePage(page.url_slug);
- } catch (err) {
- console.log(
- "Trigger databaseWebhook [Page Settings]: Revalidation failed, most likely the page is deleted:",
- err
- );
- }
-
return res.status(200).json({ ok: true });
} catch (err) {
console.log("Trigger databaseWebhook [Page Settings]: Error:", err);
diff --git a/apps/web/pages/api/pages/webhook.ts b/apps/web/pages/api/pages/webhook.ts
index 3ea9aad..a1783da 100644
--- a/apps/web/pages/api/pages/webhook.ts
+++ b/apps/web/pages/api/pages/webhook.ts
@@ -1,7 +1,6 @@
import { IPage } from "@changes-page/supabase/types/page";
import { NextApiRequest, NextApiResponse } from "next";
import { v4 } from "uuid";
-import { revalidatePage } from "../../../utils/revalidate";
import {
createOrRetrievePageSettings,
updateSubscriptionUsage,
@@ -26,14 +25,8 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
// report usage
await updateSubscriptionUsage(user_id, `${type}-${id}-${v4()}`);
- // Revalidate
- await revalidatePage(page.url_slug);
-
try {
- const settings = await createOrRetrievePageSettings(page.id);
- if (settings?.custom_domain) {
- await revalidatePage(settings.custom_domain);
- }
+ await createOrRetrievePageSettings(page.id);
} catch (err) {
console.log(
"Trigger databaseWebhook [Page]: Revalidation failed, failed to get page settings, maybe page is deleted?:",
diff --git a/apps/web/pages/api/posts/webhook.ts b/apps/web/pages/api/posts/webhook.ts
index 7b578a9..67a02c1 100644
--- a/apps/web/pages/api/posts/webhook.ts
+++ b/apps/web/pages/api/posts/webhook.ts
@@ -4,7 +4,6 @@ import { NextApiRequest, NextApiResponse } from "next";
import { DELETE_IMAGES_JOB_EVENT } from "../../../inngest/jobs/delete-images";
import { sendPostEmailToSubscribers } from "../../../utils/email";
import inngestClient from "../../../utils/inngest";
-import { revalidatePage } from "../../../utils/revalidate";
import {
createOrRetrievePageSettings,
getPageById,
@@ -37,21 +36,6 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
},
});
- try {
- const page = await getPageById(page_id);
- const settings = await createOrRetrievePageSettings(page_id);
- // Revalidate
- await revalidatePage(page.url_slug);
- if (settings?.custom_domain) {
- await revalidatePage(settings.custom_domain);
- }
- } catch (err) {
- console.log(
- "Trigger databaseWebhook [Posts]: Error revalidating page, most likely its deleted:",
- err
- );
- }
-
return res.status(200).json({ ok: true });
}
@@ -96,12 +80,6 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => {
.update({ email_notified: true })
.match({ id: post.id });
}
-
- // Revalidate
- await revalidatePage(page.url_slug);
- if (settings?.custom_domain) {
- await revalidatePage(settings.custom_domain);
- }
}
return res.status(200).json({ ok: true });
diff --git a/apps/web/utils/revalidate.ts b/apps/web/utils/revalidate.ts
deleted file mode 100644
index 5ad6e80..0000000
--- a/apps/web/utils/revalidate.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { httpPost } from "./http";
-
-export const revalidatePage = async (path: string) => {
- try {
- const revalidateEndpoint =
- process.env.REVALIDATE_ENDPOINT || "https://hey.changes.page";
-
- const result = await httpPost({
- url: `${revalidateEndpoint}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}`,
- data: { path },
- });
-
- console.log("revalidatePage: Result:", path, result);
- } catch (error) {
- console.error("revalidatePage failed: Error:", error);
- }
-};
From 6df4d910ac480e7f93a09e0ba9a4d3a24ec8fb92 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 22:05:15 +1100
Subject: [PATCH 10/24] Add audit logs
---
apps/page/pages/_sites/[site]/index.tsx | 7 -
.../components/marketing/pricing-section.tsx | 2 +-
apps/web/components/post/post.tsx | 9 +-
apps/web/pages/api/posts/index.ts | 13 +-
apps/web/pages/pages/[page_id]/[post_id].tsx | 9 +-
apps/web/pages/pages/[page_id]/audit-logs.tsx | 123 ++++++++++++++++++
apps/web/pages/pages/[page_id]/index.tsx | 86 ++++++------
apps/web/utils/hooks/usePageSettings.ts | 9 +-
apps/web/utils/useSSR.ts | 2 +-
packages/supabase/migrations/16_teams.sql | 15 ++-
packages/supabase/types/index.ts | 49 +++++++
11 files changed, 272 insertions(+), 52 deletions(-)
create mode 100644 apps/web/pages/pages/[page_id]/audit-logs.tsx
diff --git a/apps/page/pages/_sites/[site]/index.tsx b/apps/page/pages/_sites/[site]/index.tsx
index 7f2a83e..fa8f1a8 100644
--- a/apps/page/pages/_sites/[site]/index.tsx
+++ b/apps/page/pages/_sites/[site]/index.tsx
@@ -149,13 +149,6 @@ export default function Index({
);
}
-// export async function getStaticPaths() {
-// return {
-// paths: [],
-// fallback: "blocking",
-// };
-// }
-
export async function getServerSideProps({
params: { site },
}: {
diff --git a/apps/web/components/marketing/pricing-section.tsx b/apps/web/components/marketing/pricing-section.tsx
index 397b6fd..5dcc558 100644
--- a/apps/web/components/marketing/pricing-section.tsx
+++ b/apps/web/components/marketing/pricing-section.tsx
@@ -15,7 +15,7 @@ export default function PricingSection({ unit_amount = 500, addons = [] }) {
"Zapier Integration",
"White labeling",
"AI Assistant",
- "Email Support",
+ "Email & Slack Support",
];
return (
diff --git a/apps/web/components/post/post.tsx b/apps/web/components/post/post.tsx
index 04be81a..99421b2 100644
--- a/apps/web/components/post/post.tsx
+++ b/apps/web/components/post/post.tsx
@@ -35,7 +35,7 @@ export function Post({
settings: IPageSettings;
updatePageSettings: (settings: IPageSettings) => void;
}) {
- const { supabase } = useUserData();
+ const { supabase, user } = useUserData();
const [openDeletePost, setOpenDeletePost] = useState(false);
const [deletePost, setDeletePost] = useState(null);
@@ -54,6 +54,13 @@ export function Post({
try {
await supabase.from("posts").delete().eq("id", post.id);
+ await supabase.from("page_audit_logs").insert({
+ page_id: page.id,
+ actor_id: user.id,
+ action: "Deleted Post",
+ changes: post,
+ });
+
fetchPosts();
setIsDeleting(false);
setOpenDeletePost(false);
diff --git a/apps/web/pages/api/posts/index.ts b/apps/web/pages/api/posts/index.ts
index 16463d8..77cd50d 100644
--- a/apps/web/pages/api/posts/index.ts
+++ b/apps/web/pages/api/posts/index.ts
@@ -24,7 +24,7 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => {
try {
await apiRateLimiter(req, res);
- const { user } = await getSupabaseServerClient({ req, res });
+ const { user, supabase } = await getSupabaseServerClient({ req, res });
const isValid = await NewPostSchema.isValid({
page_id,
@@ -48,7 +48,7 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => {
console.log("createNewPost", user?.id);
- const post = await createPost({
+ const postPayload = {
user_id: user.id,
page_id,
title,
@@ -63,6 +63,15 @@ const createNewPost = async (req: NextApiRequest, res: NextApiResponse) => {
notes: notes ?? "",
allow_reactions: allow_reactions ?? false,
email_notified: email_notified ?? false,
+ };
+
+ const post = await createPost(postPayload);
+
+ await supabase.from("page_audit_logs").insert({
+ page_id,
+ actor_id: user.id,
+ action: `Created Post: ${post.title}`,
+ changes: postPayload,
});
return res.status(201).json({ post });
diff --git a/apps/web/pages/pages/[page_id]/[post_id].tsx b/apps/web/pages/pages/[page_id]/[post_id].tsx
index b65eaf2..01f83c5 100644
--- a/apps/web/pages/pages/[page_id]/[post_id].tsx
+++ b/apps/web/pages/pages/[page_id]/[post_id].tsx
@@ -42,7 +42,7 @@ export default function EditPost({
post,
settings,
}: InferGetServerSidePropsType) {
- const { supabase } = useUserData();
+ const { supabase, user } = useUserData();
const router = useRouter();
const [saving, setSaving] = useState(false);
@@ -60,6 +60,13 @@ export default function EditPost({
await supabase.from("posts").update(newPost).match({ id: post_id });
+ await supabase.from("page_audit_logs").insert({
+ page_id: page_id,
+ actor_id: user.id,
+ action: `Updated Post: ${newPost.title}`,
+ changes: newPost,
+ });
+
return await router.push(`${ROUTES.PAGES}/${page_id}`);
} catch (e) {
setSaving(false);
diff --git a/apps/web/pages/pages/[page_id]/audit-logs.tsx b/apps/web/pages/pages/[page_id]/audit-logs.tsx
new file mode 100644
index 0000000..22a251d
--- /dev/null
+++ b/apps/web/pages/pages/[page_id]/audit-logs.tsx
@@ -0,0 +1,123 @@
+import { supabaseAdmin } from "@changes-page/supabase/admin";
+import { DateTime } from "@changes-page/utils";
+import { ClockIcon, UserIcon } from "@heroicons/react/solid";
+import { InferGetServerSidePropsType } from "next";
+import AuthLayout from "../../../components/layout/auth-layout.component";
+import Page from "../../../components/layout/page.component";
+import { ROUTES } from "../../../data/routes.data";
+import { getSupabaseServerClient } from "../../../utils/supabase/supabase-admin";
+import { getPage } from "../../../utils/useSSR";
+
+export async function getServerSideProps({ req, res, query }) {
+ const { page_id } = query;
+
+ const { supabase } = await getSupabaseServerClient({ req, res });
+ const page = await getPage(supabase, page_id);
+ const { data: audit_logs } = await supabaseAdmin
+ .from("page_audit_logs")
+ .select("*, actor:actor_id(full_name)")
+ .eq("page_id", page_id)
+ .limit(100)
+ .order("created_at", { ascending: false });
+
+ return {
+ props: {
+ page,
+ page_id,
+ audit_logs: audit_logs ?? [],
+ },
+ };
+}
+
+export default function PageAnalytics({
+ page,
+ page_id,
+ audit_logs,
+}: InferGetServerSidePropsType) {
+ return (
+
+
+ {audit_logs.length > 0 ? (
+
+ {audit_logs.map((log) => (
+ -
+
+
+
+
+
+ {log.actor?.full_name || log.actor_id}
+
+
+ {log.action}
+
+
+
+
+
+
+
+
+
+ {log.changes && Object.keys(log.changes).length > 0 && (
+
+
+ Changes
+
+
+ {Object.entries(log.changes)
+ .filter(([key]) => key !== "created_at")
+ .filter(
+ ([key]) =>
+ !key.includes("id") &&
+ !key.includes("image") &&
+ !key.includes("at")
+ )
+
+ .map(([key, value]) => (
+
+
+ {key.replace(/_/g, " ")}:
+
+
+ {typeof value === "object"
+ ? JSON.stringify(value, null, 2).slice(0, 100) +
+ (JSON.stringify(value).length > 100
+ ? "..."
+ : "")
+ : String(value).slice(0, 100) +
+ (String(value).length > 100 ? "..." : "")}
+
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ ) : (
+
No audit logs found
+ )}
+
+
+ );
+}
+
+PageAnalytics.getLayout = function getLayout(page: any) {
+ return {page};
+};
diff --git a/apps/web/pages/pages/[page_id]/index.tsx b/apps/web/pages/pages/[page_id]/index.tsx
index 542aa2a..d081f4c 100644
--- a/apps/web/pages/pages/[page_id]/index.tsx
+++ b/apps/web/pages/pages/[page_id]/index.tsx
@@ -78,7 +78,7 @@ export default function PageDetail({
settings: serverSettings,
}: InferGetServerSidePropsType) {
const router = useRouter();
- const { supabase } = useUserData();
+ const { supabase, user } = useUserData();
const { status } = router.query;
const [search, setSearch] = useState("");
@@ -88,6 +88,8 @@ export default function PageDetail({
false
);
+ const isPageOwner = useMemo(() => page?.user_id === user?.id, [page, user]);
+
const settings = useMemo(
() => clientSettings ?? serverSettings,
[serverSettings, clientSettings]
@@ -175,29 +177,37 @@ export default function PageDetail({
[router, page_id]
);
- const managePageLinks = useMemo(
- () =>
- pageUrl
- ? [
- {
- label: "Analytics",
- href: `${ROUTES.PAGES}/${page_id}/analytics`,
- icon: (props) => ,
- },
- {
- label: "Settings",
- href: `${ROUTES.PAGES}/${page_id}/settings/general`,
- icon: (props) => ,
- },
- {
- label: "Edit Page",
- href: `${ROUTES.PAGES}/${page_id}/edit`,
- icon: (props) => ,
- },
- ]
- : [],
- [pageUrl, page_id]
- );
+ const managePageLinks = useMemo(() => {
+ const links = pageUrl
+ ? [
+ {
+ label: "Analytics",
+ href: `${ROUTES.PAGES}/${page_id}/analytics`,
+ icon: (props) => ,
+ },
+ {
+ label: "Settings",
+ href: `${ROUTES.PAGES}/${page_id}/settings/general`,
+ icon: (props) => ,
+ },
+ {
+ label: "Audit Logs",
+ href: `${ROUTES.PAGES}/${page_id}/audit-logs`,
+ icon: (props) => ,
+ },
+ ]
+ : [];
+
+ if (isPageOwner) {
+ links.push({
+ label: "Edit Page",
+ href: `${ROUTES.PAGES}/${page_id}/edit`,
+ icon: (props) => ,
+ });
+ }
+
+ return links;
+ }, [pageUrl, page_id, isPageOwner]);
const pageLinks = useMemo(
() =>
@@ -264,7 +274,7 @@ export default function PageDetail({
))}
-
-
- }
- onClick={() => setOpenDeletePage(true)}
- />
-
+ {isPageOwner && (
+
+
+ }
+ onClick={() => setOpenDeletePage(true)}
+ />
+
+ )}
>
}
>
diff --git a/apps/web/utils/hooks/usePageSettings.ts b/apps/web/utils/hooks/usePageSettings.ts
index ba9ea54..c759d7e 100644
--- a/apps/web/utils/hooks/usePageSettings.ts
+++ b/apps/web/utils/hooks/usePageSettings.ts
@@ -9,7 +9,7 @@ import { httpGet } from "../http";
import { useUserData } from "../useUser";
export default function usePageSettings(pageId: string, prefetch = true) {
- const { supabase } = useUserData();
+ const { supabase, user } = useUserData();
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState
(null);
@@ -22,6 +22,13 @@ export default function usePageSettings(pageId: string, prefetch = true) {
.match({ page_id: pageId })
.select();
+ await supabase.from("page_audit_logs").insert({
+ page_id: pageId,
+ actor_id: user.id,
+ action: "Updated Page Settings",
+ changes: payload,
+ });
+
if (settings.length) {
setSettings(settings[0]);
}
diff --git a/apps/web/utils/useSSR.ts b/apps/web/utils/useSSR.ts
index f8f4a29..34d723e 100644
--- a/apps/web/utils/useSSR.ts
+++ b/apps/web/utils/useSSR.ts
@@ -3,7 +3,7 @@ import { SupabaseClient } from "@supabase/auth-helpers-nextjs";
export async function getPage(supabase: SupabaseClient, id: string) {
const { data: page } = await supabase
.from("pages")
- .select("id,title,type,description,url_slug")
+ .select("id,title,type,description,url_slug,user_id")
.eq("id", id)
.maybeSingle()
.throwOnError();
diff --git a/packages/supabase/migrations/16_teams.sql b/packages/supabase/migrations/16_teams.sql
index 32ef6f7..78cd75a 100644
--- a/packages/supabase/migrations/16_teams.sql
+++ b/packages/supabase/migrations/16_teams.sql
@@ -97,4 +97,17 @@ alter table page_settings drop column user_id;
-- Update posts
alter policy "Can view own posts." on posts using (page_id in (select id from pages));
alter policy "Can update own posts." on posts using (page_id in (select id from pages));
-alter policy "Can delete own posts." on posts using (page_id in (select id from pages));
\ No newline at end of file
+alter policy "Can delete own posts." on posts using (page_id in (select id from pages));
+
+-- Audit logs table
+create table page_audit_logs (
+ id uuid default uuid_generate_v4() primary key,
+ page_id uuid references pages not null,
+ actor_id uuid references users not null,
+ action text not null,
+ changes jsonb,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table page_audit_logs enable row level security;
+create policy "Can insert page audit logs." on page_audit_logs for insert with check (actor_id = auth.uid());
\ No newline at end of file
diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts
index afeb7d3..6b653b2 100644
--- a/packages/supabase/types/index.ts
+++ b/packages/supabase/types/index.ts
@@ -9,6 +9,48 @@ export type Json =
export type Database = {
public: {
Tables: {
+ page_audit_logs: {
+ Row: {
+ action: string
+ actor_id: string
+ changes: Json | null
+ created_at: string
+ id: string
+ page_id: string
+ }
+ Insert: {
+ action: string
+ actor_id: string
+ changes?: Json | null
+ created_at?: string
+ id?: string
+ page_id: string
+ }
+ Update: {
+ action?: string
+ actor_id?: string
+ changes?: Json | null
+ created_at?: string
+ id?: string
+ page_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "page_audit_logs_actor_id_fkey"
+ columns: ["actor_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "page_audit_logs_page_id_fkey"
+ columns: ["page_id"]
+ isOneToOne: false
+ referencedRelation: "pages"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
page_email_subscribers: {
Row: {
created_at: string
@@ -517,6 +559,13 @@ export type Database = {
}
Returns: boolean
}
+ is_team_member: {
+ Args: {
+ tid: string
+ uid: string
+ }
+ Returns: boolean
+ }
page_view_browsers: {
Args: {
pageid: string
From 98171f4403646cd101a12ac0d29a4f431dfb96f8 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 22:31:05 +1100
Subject: [PATCH 11/24] Make UI nicer
---
apps/web/pages/pages/[page_id]/audit-logs.tsx | 35 +++++++++++++------
apps/web/pages/pages/index.tsx | 9 ++---
2 files changed, 26 insertions(+), 18 deletions(-)
diff --git a/apps/web/pages/pages/[page_id]/audit-logs.tsx b/apps/web/pages/pages/[page_id]/audit-logs.tsx
index 22a251d..a0356fe 100644
--- a/apps/web/pages/pages/[page_id]/audit-logs.tsx
+++ b/apps/web/pages/pages/[page_id]/audit-logs.tsx
@@ -1,6 +1,13 @@
import { supabaseAdmin } from "@changes-page/supabase/admin";
+import { Timeline } from "@changes-page/ui";
import { DateTime } from "@changes-page/utils";
-import { ClockIcon, UserIcon } from "@heroicons/react/solid";
+import {
+ ClockIcon,
+ PencilIcon,
+ PlusIcon,
+ TrashIcon,
+ UserIcon,
+} from "@heroicons/react/solid";
import { InferGetServerSidePropsType } from "next";
import AuthLayout from "../../../components/layout/auth-layout.component";
import Page from "../../../components/layout/page.component";
@@ -41,19 +48,25 @@ export default function PageAnalytics({
backRoute={`${ROUTES.PAGES}/${page_id}`}
showBackButton={true}
>
-
+
+
{audit_logs.length > 0 ? (
{audit_logs.map((log) => (
- -
+
-
-
+
-
-
+
+ {log.action.toLowerCase().includes("delete") ? (
+
+ ) : log.action.toLowerCase().includes("create") ? (
+
+ ) : log.action.toLowerCase().includes("update") ? (
+
+ ) : (
+
+ )}
@@ -74,7 +87,7 @@ export default function PageAnalytics({
{log.changes && Object.keys(log.changes).length > 0 && (
-
+
Changes
@@ -87,7 +100,7 @@ export default function PageAnalytics({
!key.includes("image") &&
!key.includes("at")
)
-
+ .filter(([_, value]) => value !== null && value !== "")
.map(([key, value]) => (
diff --git a/apps/web/pages/pages/index.tsx b/apps/web/pages/pages/index.tsx
index 9c4d060..3c2b957 100644
--- a/apps/web/pages/pages/index.tsx
+++ b/apps/web/pages/pages/index.tsx
@@ -1,5 +1,5 @@
import { PageType, PageTypeToLabel } from "@changes-page/supabase/types/page";
-import { PlusIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
+import { PlusIcon, UserGroupIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { useRouter } from "next/router";
@@ -161,12 +161,7 @@ export default function Pages({
Editor ({page.teams.name})
- ) : (
-
-
- Owner
-
- )}
+ ) : null}
))}
From 3fdbe7ffebf7a95078d1e4cb212d6ce843c6b2a5 Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Sun, 9 Feb 2025 22:35:43 +1100
Subject: [PATCH 12/24] Show empty values in audit logs
---
apps/web/pages/pages/[page_id]/audit-logs.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/web/pages/pages/[page_id]/audit-logs.tsx b/apps/web/pages/pages/[page_id]/audit-logs.tsx
index a0356fe..41b716e 100644
--- a/apps/web/pages/pages/[page_id]/audit-logs.tsx
+++ b/apps/web/pages/pages/[page_id]/audit-logs.tsx
@@ -100,7 +100,6 @@ export default function PageAnalytics({
!key.includes("image") &&
!key.includes("at")
)
- .filter(([_, value]) => value !== null && value !== "")
.map(([key, value]) => (
From 58c8a18bcc4ab9fd0a201d3d99bb4612294bde0a Mon Sep 17 00:00:00 2001
From: Arjun Komath
Date: Mon, 10 Feb 2025 20:41:54 +1100
Subject: [PATCH 13/24] Minor UI fixes, tracking teams feature
---
.../dialogs/manage-team-dialog.component.tsx | 3 +
.../layout/auth-layout.component.tsx | 6 +
apps/web/components/layout/page.component.tsx | 4 +-
apps/web/next.config.js | 2 +-
apps/web/pages/pages/[page_id]/index.tsx | 2 +-
apps/web/pages/pages/[page_id]/new.tsx | 2 +-
apps/web/pages/pages/index.tsx | 8 +-
apps/web/pages/teams/index.tsx | 178 +++++++++++-------
8 files changed, 123 insertions(+), 82 deletions(-)
diff --git a/apps/web/components/dialogs/manage-team-dialog.component.tsx b/apps/web/components/dialogs/manage-team-dialog.component.tsx
index 05b75df..8133d3a 100644
--- a/apps/web/components/dialogs/manage-team-dialog.component.tsx
+++ b/apps/web/components/dialogs/manage-team-dialog.component.tsx
@@ -7,6 +7,7 @@ import { Fragment, useEffect, useRef } from "react";
import { InferType } from "yup";
import { ROUTES } from "../../data/routes.data";
import { NewTeamSchema } from "../../data/schema";
+import { track } from "../../utils/analytics";
import { useUserData } from "../../utils/useUser";
import { InlineErrorMessage } from "../forms/notification.component";
@@ -65,6 +66,7 @@ export default function ManageTeamDialog({
image: values.image,
})
.match({ id: team.id });
+ track("EditTeam");
onSuccess();
} else {
await supabase
@@ -77,6 +79,7 @@ export default function ManageTeamDialog({
},
])
.select();
+ track("CreateTeam");
onSuccess();
}
diff --git a/apps/web/components/layout/auth-layout.component.tsx b/apps/web/components/layout/auth-layout.component.tsx
index 5a527fc..5b19e2a 100644
--- a/apps/web/components/layout/auth-layout.component.tsx
+++ b/apps/web/components/layout/auth-layout.component.tsx
@@ -5,6 +5,12 @@ export default function AuthLayout({ children }) {
{children}
+
);
}
diff --git a/apps/web/components/layout/page.component.tsx b/apps/web/components/layout/page.component.tsx
index 6980c04..b693668 100644
--- a/apps/web/components/layout/page.component.tsx
+++ b/apps/web/components/layout/page.component.tsx
@@ -81,7 +81,7 @@ export default function Page({
!!title && tabs.length > 0 && "relative sm:pb-0 lg:pb-2"
)}
>
-
+
{showBackButton && (
{!!title && tabs?.length > 0 && (
-
+