diff --git a/apps/analytics/app/_components/analytics/AnalyticsView.tsx b/apps/analytics/app/_components/analytics/AnalyticsView.tsx index c7533a4d..e32024ee 100644 --- a/apps/analytics/app/_components/analytics/AnalyticsView.tsx +++ b/apps/analytics/app/_components/analytics/AnalyticsView.tsx @@ -5,8 +5,13 @@ import TotalInterviewsCompletedCard from "./cards/TotalInterviewsCompletedCard"; import TotalInterviewsStartedCard from "./cards/TotalInterviewsStartedCard"; import TotalProtocolsInstalledCard from "./cards/TotalProtocolsInstalledCard"; import RegionsTable from "./RegionsTable/RegionsTable"; +import getEvents from "~/db/getEvents"; +import TotalErrorsCard from "./cards/TotalErrorsCard"; +import TotalDataExported from "./cards/TotalDataExported"; + +export default async function AnalyticsView() { + const events = await getEvents(); -export default function AnalyticsView() { return (
@@ -14,10 +19,14 @@ export default function AnalyticsView() { + +
-
- - +
+
+ +
+ Regions diff --git a/apps/analytics/app/_components/analytics/EventsTable/Columns.tsx b/apps/analytics/app/_components/analytics/EventsTable/Columns.tsx index 425dec0d..ab56e635 100644 --- a/apps/analytics/app/_components/analytics/EventsTable/Columns.tsx +++ b/apps/analytics/app/_components/analytics/EventsTable/Columns.tsx @@ -1,39 +1,84 @@ "use client"; -import { DataTableColumnHeader } from "~/components/DataTable/column-header"; + import { ColumnDef } from "@tanstack/react-table"; -import type { Event } from "~/db/schema"; -import { MetadataDialog } from "./MetadataDialog"; -export const columns: ColumnDef[] = [ - { - accessorKey: "type", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "timestamp", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "installationid", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return
{row.original.installationid}
; - }, - }, - { - accessorKey: "stacktrace", - header: "", - cell: ({ row }) => { - return ( -
- +import { type Dispatch, type SetStateAction } from "react"; +import { DataTableColumnHeader } from "~/components/DataTable/column-header"; +import { MetadataDialog } from "~/components/MetadataDialog"; +import type { Event } from "~/db/getEvents"; +import { type EventType } from "./EventsTable"; +import { StackTraceDialog } from "./StackTraceDialog"; +import TableFilter from "./TableFilter"; + +export const getColumns = ( + eventTypes: EventType[], + setEventTypes: Dispatch> +) => { + const columns: ColumnDef[] = [ + { + accessorKey: "type", + header: ({ column }) => ( +
+ +
- ); + ), + }, + { + accessorKey: "timestamp", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ {row.original.timestamp.toUTCString()} +
+ ); + }, + }, + { + accessorKey: "installationId", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return
{row.original.installationId}
; + }, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "message", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "stack", + header: "", + cell: ({ row }) => + row.original.stack && ( +
+ +
+ ), + }, + { + accessorKey: "metadata", + header: "", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, }, - }, -]; + ]; + + return columns; +}; diff --git a/apps/analytics/app/_components/analytics/EventsTable/EventsTable.tsx b/apps/analytics/app/_components/analytics/EventsTable/EventsTable.tsx index b0c2766c..1b650e46 100644 --- a/apps/analytics/app/_components/analytics/EventsTable/EventsTable.tsx +++ b/apps/analytics/app/_components/analytics/EventsTable/EventsTable.tsx @@ -1,22 +1,49 @@ -// EventsTable.tsx -import React from "react"; +"use client"; + +import { useEffect, useMemo, useState } from "react"; import { DataTable } from "~/components/DataTable/data-table"; -import getEvents from "~/db/getEvents"; -import { columns } from "./Columns"; import ExportButton from "~/components/ExportButton"; +import { Event } from "~/db/getEvents"; +import { getColumns } from "./Columns"; + +export type EventType = { + text: string; + isSelected: boolean; +}; + +export default function EventsTable({ events }: { events: Event[] }) { + const [eventTypes, setEventTypes] = useState([]); + + useEffect(() => { + const eventTypesMap = new Map(); + events.forEach((event) => + eventTypesMap.set(event.type, { text: event.type, isSelected: true }) + ); + + setEventTypes([...Array.from(eventTypesMap.values())]); + }, [events]); + + const filteredEvents = useMemo(() => { + const filters = eventTypes + .filter((type) => type.isSelected) + .map((type) => type.text); -export default async function EventsTable() { - const events = await getEvents(); + return events.filter((event) => filters.includes(event.type)); + }, [eventTypes, events]); return (
-
+

Events

-
- +
+
); diff --git a/apps/analytics/app/_components/analytics/EventsTable/StackTraceDialog.tsx b/apps/analytics/app/_components/analytics/EventsTable/StackTraceDialog.tsx new file mode 100644 index 00000000..ed049cf2 --- /dev/null +++ b/apps/analytics/app/_components/analytics/EventsTable/StackTraceDialog.tsx @@ -0,0 +1,13 @@ +import { DialogButton } from "~/components/DialogButton"; +import { Event } from "~/db/getEvents"; + +export function StackTraceDialog({ error }: { error: Event }) { + return ( + + ); +} diff --git a/apps/analytics/app/_components/analytics/EventsTable/TableFilter.tsx b/apps/analytics/app/_components/analytics/EventsTable/TableFilter.tsx new file mode 100644 index 00000000..191ebad4 --- /dev/null +++ b/apps/analytics/app/_components/analytics/EventsTable/TableFilter.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, type Dispatch, type SetStateAction } from "react"; +import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { type EventType } from "./EventsTable"; + +type TableFilterProps = { + eventTypes: EventType[]; + setEventTypes: Dispatch>; +}; + +const TableFilter = ({ eventTypes, setEventTypes }: TableFilterProps) => { + const [options, setOptions] = useState(eventTypes); + + const toggleOption = (option: string) => { + setOptions((prevState) => + prevState.map((t) => + t.text === option ? { ...t, isSelected: !t.isSelected } : t + ) + ); + }; + + const toggleAllOptions = (isSelected: boolean) => { + setOptions((prevState) => prevState.map((t) => ({ ...t, isSelected }))); + }; + + const isAllSelected = options.every((option) => option.isSelected); + + return ( + + + + + + Select events + + +
+ + + + {options.map((option) => ( + + ))} + + +
+
+
+ ); +}; + +export default TableFilter; diff --git a/apps/analytics/app/_components/analytics/cards/TotalDataExported.tsx b/apps/analytics/app/_components/analytics/cards/TotalDataExported.tsx new file mode 100644 index 00000000..8325614d --- /dev/null +++ b/apps/analytics/app/_components/analytics/cards/TotalDataExported.tsx @@ -0,0 +1,15 @@ +import { getTotalInterviewsStarted } from "~/utils/getTotalInterviewsStarted"; +import { SummaryCard } from "~/components/SummaryCard"; + +const TotalDataExported = async () => { + const totalInterviewsStarted = await getTotalInterviewsStarted(); + return ( + + ); +}; + +export default TotalDataExported; diff --git a/apps/analytics/app/_components/analytics/cards/TotalErrorsCard.tsx b/apps/analytics/app/_components/analytics/cards/TotalErrorsCard.tsx new file mode 100644 index 00000000..fb0a3757 --- /dev/null +++ b/apps/analytics/app/_components/analytics/cards/TotalErrorsCard.tsx @@ -0,0 +1,15 @@ +import { SummaryCard } from "~/components/SummaryCard"; +import { getTotalErrors } from "~/utils/getTotalErrors"; + +const TotalErrorsCard = async () => { + const totalErrors = await getTotalErrors(); + return ( + + ); +}; + +export default TotalErrorsCard; diff --git a/apps/analytics/app/_components/errors/ErrorsTable/Columns.tsx b/apps/analytics/app/_components/errors/ErrorsTable/Columns.tsx deleted file mode 100644 index accb6b11..00000000 --- a/apps/analytics/app/_components/errors/ErrorsTable/Columns.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { DataTableColumnHeader } from "~/components/DataTable/column-header"; -import { ColumnDef } from "@tanstack/react-table"; -import { Error } from "~/db/schema"; -import { StackTraceDialog } from "~/app/_components/errors/ErrorsTable/StackTraceDialog"; -export const columns: ColumnDef[] = [ - { - accessorKey: "message", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "details", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "path", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "installationid", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "timestamp", - header: ({ column }) => ( - - ), - }, - { - accessorKey: "stacktrace", - header: "", - cell: ({ row }) => { - return ( -
- -
- ); - }, - }, -]; diff --git a/apps/analytics/app/_components/errors/ErrorsTable/ErrorsTable.tsx b/apps/analytics/app/_components/errors/ErrorsTable/ErrorsTable.tsx deleted file mode 100644 index 449e07d9..00000000 --- a/apps/analytics/app/_components/errors/ErrorsTable/ErrorsTable.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { DataTable } from "~/components/DataTable/data-table"; -import getErrors from "~/db/getErrors"; -import { columns } from "./Columns"; -import ExportButton from "~/components/ExportButton"; -import { Card, CardHeader, CardContent } from "~/components/ui/card"; - -export default async function ErrorsTable() { - const errors = await getErrors(); - - return ( - <> - - - - ); -} diff --git a/apps/analytics/app/_components/errors/ErrorsTable/StackTraceDialog.tsx b/apps/analytics/app/_components/errors/ErrorsTable/StackTraceDialog.tsx deleted file mode 100644 index 8245f1f5..00000000 --- a/apps/analytics/app/_components/errors/ErrorsTable/StackTraceDialog.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { DialogButton } from "~/components/DialogButton"; -import type { Error } from "~/db/schema"; - -export function StackTraceDialog({ error }: { error: Error }) { - return ( - - ); -} diff --git a/apps/analytics/app/_components/errors/ErrorsView.tsx b/apps/analytics/app/_components/errors/ErrorsView.tsx deleted file mode 100644 index 45d81e43..00000000 --- a/apps/analytics/app/_components/errors/ErrorsView.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import ErrorsTable from "./ErrorsTable/ErrorsTable"; -import TotalErrorsCard from "./cards/TotalErrorsCard"; - -export default function ErrorsView() { - return ( -
-
- -
- -
- ); -} diff --git a/apps/analytics/app/_components/errors/cards/TotalErrorsCard.tsx b/apps/analytics/app/_components/errors/cards/TotalErrorsCard.tsx deleted file mode 100644 index 50246c5a..00000000 --- a/apps/analytics/app/_components/errors/cards/TotalErrorsCard.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SummaryCard } from "~/components/SummaryCard"; -import getErrors from "~/db/getErrors"; - -const TotalErrorsCard = async () => { - const errors = await getErrors(); - const totalErrors = errors.length; - return ; -}; - -export default TotalErrorsCard; diff --git a/apps/analytics/app/api/event/route.test.ts b/apps/analytics/app/api/event/route.test.ts new file mode 100644 index 00000000..a95d815f --- /dev/null +++ b/apps/analytics/app/api/event/route.test.ts @@ -0,0 +1,97 @@ +import { testApiHandler } from "next-test-api-route-handler"; +import insertEvent from "~/db/insertEvent"; +import * as appHandler from "./route"; +import { Event } from "@codaco/analytics"; + +jest.mock("~/db/insertEvent", () => jest.fn()); + +describe("/api/event", () => { + it("should insert event to the database", async () => { + const eventData = { + type: "AppSetup", + metadata: { + details: "testing details", + path: "testing path", + }, + installationId: "21321546453213123", + timestamp: new Date().toString(), + }; + + (insertEvent as jest.Mock).mockImplementation(async (eventData: Event) => { + return { data: eventData, error: null }; + }); + + await testApiHandler({ + appHandler, + test: async ({ fetch }) => { + const response = await fetch({ + method: "POST", + body: JSON.stringify(eventData), + }); + expect(insertEvent).toHaveBeenCalledWith({ + ...eventData, + timestamp: new Date(eventData.timestamp), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ event: eventData }); + }, + }); + }); + + it("should return 400 if event is invalid", async () => { + const eventData = { + type: "InvalidEvent", + metadata: { + details: "testing details", + path: "testing path", + }, + timestamp: new Date().toString(), + }; + + await testApiHandler({ + appHandler, + test: async ({ fetch }) => { + const response = await fetch({ + method: "POST", + body: JSON.stringify(eventData), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid event" }); + }, + }); + }); + + it("should return 500 if there is an error inserting the event to the database", async () => { + const eventData = { + type: "AppSetup", + metadata: { + details: "testing details", + path: "testing path", + }, + installationId: "21321546453213123", + timestamp: new Date().toString(), + }; + + (insertEvent as jest.Mock).mockImplementation(async (eventData: Event) => { + return { data: null, error: "Error inserting events" }; + }); + + await testApiHandler({ + appHandler, + test: async ({ fetch }) => { + const response = await fetch({ + method: "POST", + body: JSON.stringify(eventData), + }); + expect(insertEvent).toHaveBeenCalledWith({ + ...eventData, + timestamp: new Date(eventData.timestamp), + }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Error inserting events", + }); + }, + }); + }); +}); diff --git a/apps/analytics/app/api/event/route.ts b/apps/analytics/app/api/event/route.ts index efc7f23d..421fbf26 100644 --- a/apps/analytics/app/api/event/route.ts +++ b/apps/analytics/app/api/event/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { sql } from "@vercel/postgres"; -import type { DispatchableAnalyticsEvent } from "@codaco/analytics"; +import insertEvent from "~/db/insertEvent"; +import { EventsSchema } from "@codaco/analytics"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -11,41 +11,32 @@ const corsHeaders = { export const runtime = "edge"; export async function POST(request: NextRequest) { - const event = (await request.json()) as DispatchableAnalyticsEvent; - - const timestamp = JSON.stringify(event.timestamp || new Date().toISOString()); - - // determine if this is an error and push it to the errors table - if (event.type === "Error") { - const errorPayload = event.error; - try { - await sql`INSERT INTO Errors (message, details, stacktrace, timestamp, installationid, path) VALUES (${errorPayload.message}, ${errorPayload.details}, ${errorPayload.stacktrace}, ${timestamp}, ${event.installationId}, ${errorPayload.path});`; - return NextResponse.json( - { errorPayload }, - { status: 200, headers: corsHeaders } - ); - } catch (error) { - return NextResponse.json( - { error }, - { status: 500, headers: corsHeaders } - ); - } + const event = (await request.json()) as unknown; + const parsedEvent = EventsSchema.safeParse(event); + + if (parsedEvent.success === false) { + return NextResponse.json( + { error: "Invalid event" }, + { status: 400, headers: corsHeaders } + ); } - // event is not an error - // push the event to the events table - try { - await sql`INSERT INTO EVENTS (type, metadata, timestamp, installationid, isocode) VALUES( - ${event.type}, - ${JSON.stringify(event.metadata)}, - ${timestamp}, - ${event.installationId}, - ${event.geolocation?.countryCode} - );`; + const result = await insertEvent({ + ...parsedEvent.data, + timestamp: new Date(parsedEvent.data.timestamp), + message: parsedEvent.data.error?.message, + name: parsedEvent.data.error?.name, + stack: parsedEvent.data.error?.stack, + }); + if (result.error) throw new Error(result.error); + return NextResponse.json({ event }, { status: 200, headers: corsHeaders }); } catch (error) { - return NextResponse.json({ error }, { status: 500, headers: corsHeaders }); + return NextResponse.json( + { error: "Error inserting events" }, + { status: 500, headers: corsHeaders } + ); } } diff --git a/apps/analytics/app/layout.tsx b/apps/analytics/app/layout.tsx index 28e0e802..821e92c8 100644 --- a/apps/analytics/app/layout.tsx +++ b/apps/analytics/app/layout.tsx @@ -6,8 +6,8 @@ import { ClerkProvider } from "@clerk/nextjs"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Fresco Analytics ", + description: "This is the analytics dashboard for Fresco.", }; export default function RootLayout({ diff --git a/apps/analytics/app/page.tsx b/apps/analytics/app/page.tsx index f327d961..74d54fbe 100644 --- a/apps/analytics/app/page.tsx +++ b/apps/analytics/app/page.tsx @@ -1,7 +1,5 @@ -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import AnalyticsView from "~/app/_components/analytics/AnalyticsView"; -import ErrorsView from "~/app/_components/errors/ErrorsView"; import { UserButton } from "@clerk/nextjs"; +import AnalyticsView from "~/app/_components/analytics/AnalyticsView"; import UserManagementDialog from "./_components/users/UserManagementDialog"; export default function DashboardPage() { @@ -16,18 +14,7 @@ export default function DashboardPage() {
- - - Analytics - Errors - - - - - - - - +
); diff --git a/apps/analytics/components/DataTable/column-header.tsx b/apps/analytics/components/DataTable/column-header.tsx index b70fc25f..7a1e2378 100644 --- a/apps/analytics/components/DataTable/column-header.tsx +++ b/apps/analytics/components/DataTable/column-header.tsx @@ -13,7 +13,7 @@ import { cn } from "~/utils/shadcn"; interface DataTableColumnHeaderProps extends React.HTMLAttributes { column: Column; - title: string; + title?: string; } export function DataTableColumnHeader({ @@ -36,11 +36,15 @@ export function DataTableColumnHeader({ > {title} {column.getIsSorted() === "desc" ? ( - + ) : column.getIsSorted() === "asc" ? ( - + ) : ( - + )} diff --git a/apps/analytics/components/DataTable/data-table-pagination.tsx b/apps/analytics/components/DataTable/data-table-pagination.tsx index 356c62c1..f731c9cf 100644 --- a/apps/analytics/components/DataTable/data-table-pagination.tsx +++ b/apps/analytics/components/DataTable/data-table-pagination.tsx @@ -25,11 +25,11 @@ export function DataTablePagination({ }: DataTablePaginationProps) { return (
-
+ {/*
{table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} row(s) selected. -
-
+
*/} +

Rows per page