mirror of
https://github.com/fergalmoran/radio-otherway.git
synced 2025-12-22 09:50:29 +00:00
Firebase all the things
This commit is contained in:
2
.env
2
.env
@@ -1,7 +1,7 @@
|
||||
QSTASH_CURRENT_SIGNING_KEY=khs3lpVBv1QtV/L9MTdXlcnoI8tTlg0aDfrFz+o8utA=
|
||||
|
||||
#auth
|
||||
GOOGLE_APPLICATION_CREDENTIALS=radio-otherway-service-account.json
|
||||
GOOGLE_APPLICATION_CREDENTIALS=serviceAccount.json
|
||||
|
||||
#calendar api
|
||||
GOOGLE_CALENDAR_PROJECT_ID=47147490249
|
||||
|
||||
5
.firebaserc
Normal file
5
.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "radio-otherway"
|
||||
}
|
||||
}
|
||||
6
.github/workflows/scheduler-cache-events.yml
vendored
6
.github/workflows/scheduler-cache-events.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name:
|
||||
Cache Jobs
|
||||
Cache Events
|
||||
on:
|
||||
# Every 5 minutes
|
||||
schedule:
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
cron:
|
||||
cache:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cache outstanding events
|
||||
run: curl -X GET https://external.dev.fergl.ie:3000/api/cron/cache
|
||||
run: curl -X GET https://external.fergl.ie/api/cron/cache
|
||||
|
||||
12
.github/workflows/scheduler-check-reminders.yml
vendored
Normal file
12
.github/workflows/scheduler-check-reminders.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name:
|
||||
Send Reminders
|
||||
on:
|
||||
# Every 5 minutes
|
||||
schedule:
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
reminders:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cache outstanding events
|
||||
run: curl -X GET https://external.fergl.ie/api/cron/reminders
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ next-env.d.ts
|
||||
|
||||
.private/
|
||||
radio-otherway-service-account.json
|
||||
serviceAccount.json
|
||||
59
.idea/codeStyles/Project.xml
generated
Normal file
59
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<HTMLCodeStyleSettings>
|
||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||
<option name="HTML_KEEP_WHITESPACES_INSIDE" value="" />
|
||||
<option name="HTML_ENFORCE_QUOTES" value="true" />
|
||||
</HTMLCodeStyleSettings>
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<VueCodeStyleSettings>
|
||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||
</VueCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Vue">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
1
.idea/web.iml
generated
1
.idea/web.iml
generated
@@ -5,6 +5,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.yarn" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
6
firebase.json
Normal file
6
firebase.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
}
|
||||
}
|
||||
4
firestore.indexes.json
Normal file
4
firestore.indexes.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
8
firestore.rules
Normal file
8
firestore.rules
Normal file
@@ -0,0 +1,8 @@
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow read, write: if true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node ./server.js",
|
||||
"dev": "NODE_OPTIONS='-r next-logger' node ./server.js",
|
||||
"debug": "node ./server.js",
|
||||
"dev-nossl": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.11",
|
||||
"@next/font": "13.1.5",
|
||||
"@prisma/client": "^4.9.0",
|
||||
"@types/node": "18.11.18",
|
||||
@@ -23,9 +25,14 @@
|
||||
"firebase": "^9.17.1",
|
||||
"firebase-admin": "^11.5.0",
|
||||
"next": "13.1.5",
|
||||
"next-logger": "^3.0.1",
|
||||
"pino": "^8.11.0",
|
||||
"pino-logflare": "^0.3.12",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-icons": "^4.7.1",
|
||||
"twilio": "^4.8.0",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19",
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
"use client";
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import "./globals.css";
|
||||
import {Inter} from "@next/font/google";
|
||||
import {NavBar} from "@/components/layout";
|
||||
import {AuthUserProvider} from "@/lib/auth/authUserContext";
|
||||
|
||||
const inter = Inter({subsets: ["latin"]});
|
||||
import { Inter } from "@next/font/google";
|
||||
import { NavBar } from "@/components/layout";
|
||||
import { AuthUserProvider } from "@/lib/auth/authUserContext";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
// const Toaster = dynamic(
|
||||
// () => import("react-hot-toast").then((c) => c.Toaster),
|
||||
// {
|
||||
// ssr: false,
|
||||
// }
|
||||
// );
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" data-theme="bumblebee">
|
||||
<head/>
|
||||
<body className={`${inter.className} h-screen`}>
|
||||
<AuthUserProvider>
|
||||
<NavBar/>
|
||||
<div className="-mt-[4rem]">
|
||||
{children}
|
||||
</div>
|
||||
</AuthUserProvider>
|
||||
</body>
|
||||
<head />
|
||||
<body className={`${inter.className} h-screen`}>
|
||||
<AuthUserProvider>
|
||||
<Toaster />
|
||||
<NavBar />
|
||||
<div className="-mt-[4rem]">{children}</div>
|
||||
</AuthUserProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
const getData = async () => {
|
||||
import RemindMeButton from "@/components/widgets/RemindMeButton";
|
||||
import { getMonthName, getTime } from "@/lib/util/dateUtils";
|
||||
import logger from "@/lib/util/logging";
|
||||
import { Show } from "@/models";
|
||||
|
||||
const getData = async (): Promise<Show[]> => {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/shows/upcoming`
|
||||
);
|
||||
return res.json();
|
||||
logger.debug("getDate", res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
|
||||
const Home = async () => {
|
||||
const results = await getData();
|
||||
return results.message ? (
|
||||
logger.debug("results", results);
|
||||
return results.length === 0 ? (
|
||||
<div className="min-h-screen hero bg-base-200">
|
||||
<div className="text-center hero-content">
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-5xl font-bold">{results.message}</h1>
|
||||
<h1 className="text-5xl font-bold">No upcoming shows found</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1 className="text-xl font-extrabold font-title text-primary-content md:text-2xl lg:text-4xl">
|
||||
Upcoming Shows
|
||||
</h1>
|
||||
<div className="px-4 mt-4 overflow-x-auto">
|
||||
<table className="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Who</th>
|
||||
<th/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results && results.map((r: any) => (
|
||||
<tr key={r.id}>
|
||||
<td>{new Date(r.date).toLocaleString("en-IE")}</td>
|
||||
<td>{r.title}</td>
|
||||
<td>
|
||||
<button className="btn">Remind me</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="container flex justify-center h-screen py-20 mx-auto">
|
||||
<div className="flex flex-col w-6/12 h-full pl-4">
|
||||
<div className="px-5 py-2 text-sm font-bold text-gray-500 bg-white border-b border-gray-300 shadow">
|
||||
Tracking events
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-full overflow-auto bg-white shadow"
|
||||
id="journal-scroll"
|
||||
>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{results &&
|
||||
results.map((show: Show) => (
|
||||
<tr
|
||||
key={show.id}
|
||||
className="relative py-1 transform scale-100 border-b-2 border-blue-100 cursor-default text-md"
|
||||
>
|
||||
<td className="pl-5 pr-3 whitespace-no-wrap">
|
||||
<div className="text-gray-400">
|
||||
{`${new Date(show.date).getDay()} ${getMonthName(show.date)}`}
|
||||
</div>
|
||||
<div>{getTime(show.date)}</div>
|
||||
</td>
|
||||
<td className="px-2 py-4 space-y-2 whitespace-no-wrap">
|
||||
<div className="font-medium leading-5 text-gray-500">
|
||||
{show.creator}
|
||||
</div>
|
||||
<div className="leading-5 text-gray-900">{show.title}</div>
|
||||
</td>
|
||||
<td className="px-2 py-4">
|
||||
<RemindMeButton show={show} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
export default Home;
|
||||
|
||||
@@ -12,8 +12,8 @@ const PrivacyComponent = () => {
|
||||
rel="noreferrer">Free
|
||||
Privacy Policy Generator</a>.</p>
|
||||
<h1>Interpretation and Definitions</h1>
|
||||
<h2>Interpretation</h2>
|
||||
<p>The words of which the initial letter is capitalized have meanings defined under the following conditions. The
|
||||
<h2>Interpretation</h2>m
|
||||
<p>The words of which the initial letter is capitalized have eanings defined under the following conditions. The
|
||||
following definitions shall have the same meaning regardless of whether they appear in singular or in
|
||||
plural.</p>
|
||||
<h2>Definitions</h2>
|
||||
|
||||
43
src/components/widgets/RemindMeButton.tsx
Normal file
43
src/components/widgets/RemindMeButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import useFirebaseAuth from "@/lib/auth/useFirebaseAuth";
|
||||
import { Show } from "@/models";
|
||||
import React from "react";
|
||||
import { MdAddAlarm } from "react-icons/md";
|
||||
import { error, success, warning } from "./toast/toastService";
|
||||
|
||||
const RemindMeButton = ({ show }: { show: Show }) => {
|
||||
const { authUser } = useFirebaseAuth();
|
||||
const createShowReminder = async () => {
|
||||
if (authUser?.uid) {
|
||||
var response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/reminders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: authUser?.uid,
|
||||
showId: show.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (response.status === 201) {
|
||||
success("Reminder created successfully");
|
||||
} else {
|
||||
error("Unable to create reminder at this time");
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
className="gap-2 btn"
|
||||
onClick={async () => await createShowReminder()}
|
||||
>
|
||||
<MdAddAlarm className="w-6 h-6" />
|
||||
Remind Me!
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemindMeButton;
|
||||
@@ -1,4 +1,6 @@
|
||||
import ErrorText from "./ErrorText";
|
||||
import InputText from "./InputText";
|
||||
import RemindMeButton from "./RemindMeButton";
|
||||
import ToastService from "./toast/toastService";
|
||||
|
||||
export { ErrorText, InputText };
|
||||
export { ErrorText, InputText, RemindMeButton, ToastService };
|
||||
|
||||
82
src/components/widgets/toast/ToastComponent.tsx
Normal file
82
src/components/widgets/toast/ToastComponent.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import {
|
||||
RiAlarmWarningLine,
|
||||
RiErrorWarningLine,
|
||||
RiShieldCheckLine,
|
||||
} from "react-icons/ri";
|
||||
import { CgCloseO } from "react-icons/cg";
|
||||
|
||||
enum ToastType {
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
interface IToastComponentProps {
|
||||
title: string;
|
||||
body: string;
|
||||
type: ToastType;
|
||||
isVisible: boolean;
|
||||
onToastClicked: () => void;
|
||||
}
|
||||
const ToastComponent = ({
|
||||
title,
|
||||
body,
|
||||
type,
|
||||
isVisible,
|
||||
onToastClicked,
|
||||
}: IToastComponentProps) => {
|
||||
const _getToastIcon = (type: ToastType): React.ReactNode => {
|
||||
switch (type) {
|
||||
case ToastType.success:
|
||||
return <RiShieldCheckLine className="w-10 h-10 text-white" />;
|
||||
case ToastType.warning:
|
||||
return <RiAlarmWarningLine className="w-10 h-10 text-white" />;
|
||||
case ToastType.error:
|
||||
return <RiErrorWarningLine className="w-10 h-10 text-white" />;
|
||||
}
|
||||
};
|
||||
|
||||
function _getBackgroundColour(type: ToastType) {
|
||||
switch (type) {
|
||||
case ToastType.success:
|
||||
return "alert-success";
|
||||
case ToastType.warning:
|
||||
return "alert-warning";
|
||||
case ToastType.error:
|
||||
return "alert-error";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 w-96 text-base-content sm:items-end">
|
||||
<div
|
||||
className={`pointer-events-auto flex w-full max-w-md rounded-lg shadow-lg ${_getBackgroundColour(
|
||||
type
|
||||
)}`}
|
||||
>
|
||||
<div className="flex-1 w-0 p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 pt-0.5">{_getToastIcon(type)}</div>
|
||||
<div className="flex-1 w-0 ml-3">
|
||||
<p className="text-sm font-medium ">{title}</p>
|
||||
<p className="mt-1 text-sm ">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex border-l border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
title="Close"
|
||||
className="flex items-center justify-center w-full p-4 text-sm font-medium border border-transparent rounded-none rounded-r-lg focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
onClick={() => onToastClicked()}
|
||||
>
|
||||
<CgCloseO className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastComponent;
|
||||
export { ToastType };
|
||||
55
src/components/widgets/toast/toastService.tsx
Normal file
55
src/components/widgets/toast/toastService.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import ToastComponent, { ToastType } from "./ToastComponent";
|
||||
import {
|
||||
RiAlarmWarningLine,
|
||||
RiErrorWarningLine,
|
||||
RiShieldCheckLine
|
||||
} from "react-icons/ri";
|
||||
|
||||
const success = (message: string, title?: string) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastComponent
|
||||
title={title ?? "Success"}
|
||||
body={message}
|
||||
isVisible={t.visible}
|
||||
type={ToastType.success}
|
||||
onToastClicked={() => {
|
||||
toast.dismiss(t.id);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
const warning = (message: string, title?: string) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastComponent
|
||||
title={title ?? "Warning"}
|
||||
body={message}
|
||||
isVisible={t.visible}
|
||||
type={ToastType.warning}
|
||||
onToastClicked={() => {
|
||||
toast.dismiss(t.id);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
const error = (message: string, title?: string) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastComponent
|
||||
title={title ?? "Error"}
|
||||
body={message}
|
||||
type={ToastType.error}
|
||||
isVisible={t.visible}
|
||||
onToastClicked={() => {
|
||||
toast.dismiss(t.id);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
export { success, warning, error };
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {createContext, useContext, Context} from 'react'
|
||||
import useFirebaseAuth from "@/lib/auth/useFirebaseAuth";
|
||||
import {User} from "@/lib/auth/user";
|
||||
import {User} from "@/models";
|
||||
|
||||
interface IAuthUserContext {
|
||||
authUser: User | undefined;
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
signOut,
|
||||
TwitterAuthProvider
|
||||
} from 'firebase/auth';
|
||||
import {app} from './firebase';
|
||||
import {User} from "@/lib/auth/user";
|
||||
import {app} from '../db/firebaseAuth';
|
||||
import {useRouter} from "next/navigation";
|
||||
import {User} from "@/models";
|
||||
|
||||
const formatAuthUser = (user: User) => ({
|
||||
uid: user.uid,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type User = {
|
||||
uid: string;
|
||||
email: string
|
||||
}
|
||||
26
src/lib/db/db
Normal file
26
src/lib/db/db
Normal file
@@ -0,0 +1,26 @@
|
||||
import { firestore } from "firebase-admin";
|
||||
|
||||
// Import or define your types
|
||||
// import { YourType } from '~/@types'
|
||||
|
||||
import { Show, Reminder, Notification } from "@/models";
|
||||
import firebase from "firebase/compat";
|
||||
import FirestoreDataConverter = firebase.firestore.FirestoreDataConverter;
|
||||
|
||||
const converter = <T>() => ({
|
||||
toFirestore: (data: Partial<T>): FirebaseFirestore.DocumentData => data,
|
||||
fromFirestore: (snap: FirebaseFirestore.QueryDocumentSnapshot): T => snap.data
|
||||
});
|
||||
|
||||
|
||||
const dataPoint = <T>(collectionPath: string) => firestore()
|
||||
.collection(collectionPath)
|
||||
.withConverter(converter<T>());
|
||||
|
||||
const db = {
|
||||
shows: dataPoint<Show>("shows"),
|
||||
reminders: dataPoint<Reminder>("reminders")
|
||||
};
|
||||
|
||||
export { db };
|
||||
export default db;
|
||||
@@ -1,6 +1,6 @@
|
||||
import firebase, {getApp, getApps, initializeApp} from 'firebase/app';
|
||||
import 'firebase/auth';
|
||||
import {getFirestore} from "firebase/firestore";
|
||||
import firebase, { getApp, getApps, initializeApp } from "firebase/app";
|
||||
import "firebase/auth";
|
||||
// import { getFirestore } from "firebase/firestore";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyDtk_Ym-AZroXsHvQVcdHXYyc_TvgycAWw",
|
||||
@@ -9,7 +9,7 @@ const firebaseConfig = {
|
||||
storageBucket: "radio-otherway.appspot.com",
|
||||
messagingSenderId: "47147490249",
|
||||
appId: "1:47147490249:web:a84515b3ce1c481826e618",
|
||||
measurementId: "G-12YB78EZM4"
|
||||
measurementId: "G-12YB78EZM4",
|
||||
};
|
||||
|
||||
// export default admin.firestore();
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
28
src/lib/db/index.ts
Normal file
28
src/lib/db/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import serviceAccount from "serviceAccount.json";
|
||||
import { initializeApp } from "firebase/app";
|
||||
import { getFirestore, CollectionReference, collection, DocumentData } from "firebase/firestore";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyDtk_Ym-AZroXsHvQVcdHXYyc_TvgycAWw",
|
||||
authDomain: "radio-otherway.firebaseapp.com",
|
||||
projectId: "radio-otherway",
|
||||
storageBucket: "radio-otherway.appspot.com",
|
||||
messagingSenderId: "47147490249",
|
||||
appId: "1:47147490249:web:a84515b3ce1c481826e618",
|
||||
measurementId: "G-12YB78EZM4"
|
||||
};
|
||||
export const firebaseApp = initializeApp(firebaseConfig);
|
||||
const firestore = getFirestore();
|
||||
const createCollection = <T = DocumentData>(collectionName: string) => {
|
||||
return collection(firestore, collectionName) as CollectionReference<T>;
|
||||
};
|
||||
|
||||
|
||||
// Import all your model types
|
||||
import { Show, Reminder } from "@/models";
|
||||
// export all your collections
|
||||
|
||||
export const shows = createCollection<Show>("shows");
|
||||
export const reminders = createCollection<Reminder>("reminders");
|
||||
|
||||
export default firestore;
|
||||
19
src/lib/util/dateUtils.ts
Normal file
19
src/lib/util/dateUtils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const getMonthName = (date: string) =>
|
||||
new Date(date).toLocaleString("default", { month: "short" });
|
||||
const getTime = (date: string) =>
|
||||
new Date(date).toLocaleTimeString("en-IE", { timeStyle: "short" });
|
||||
|
||||
const getStartOfToday = (admin: any) => {
|
||||
const now = new Date();
|
||||
now.setHours(5, 0, 0, 0); // +5 hours for Eastern Time
|
||||
const timestamp = admin.firestore.Timestamp.fromDate(now);
|
||||
return timestamp; // ex. 1631246400
|
||||
};
|
||||
const dateDifferenceInSeconds = (date1: Date, date2: Date) =>
|
||||
Math.abs((date1.getTime() - date2.getTime())) / 1000;
|
||||
|
||||
const addSeconds = (date: Date, seconds: number) => {
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
return date;
|
||||
};
|
||||
export { getMonthName, getTime, getStartOfToday, dateDifferenceInSeconds, addSeconds };
|
||||
35
src/lib/util/google/calendarReader.ts
Normal file
35
src/lib/util/google/calendarReader.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Show } from "@/models";
|
||||
const { google } = require("googleapis");
|
||||
const GOOGLE_PRIVATE_KEY = process.env.GOOGLE_CALENDAR_CREDENTIALS_PRIVATE_KEY;
|
||||
const GOOGLE_CLIENT_EMAIL =
|
||||
process.env.GOOGLE_CALENDAR_CREDENTIALS_CLIENT_EMAIL;
|
||||
const GOOGLE_PROJECT_NUMBER = process.env.GOOGLE_CALENDAR_PROJECT_ID;
|
||||
const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID;
|
||||
const SCOPES = ["https://www.googleapis.com/auth/calendar"];
|
||||
|
||||
const jwtClient = new google.auth.JWT(
|
||||
GOOGLE_CLIENT_EMAIL,
|
||||
null,
|
||||
GOOGLE_PRIVATE_KEY,
|
||||
SCOPES
|
||||
);
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
project: GOOGLE_PROJECT_NUMBER,
|
||||
auth: jwtClient,
|
||||
});
|
||||
const getCalendarEntries = async () => {
|
||||
try {
|
||||
const events = await calendar.events.list({
|
||||
calendarId: GOOGLE_CALENDAR_ID,
|
||||
timeMin: new Date().toISOString(),
|
||||
maxResults: 10,
|
||||
singleEvents: true,
|
||||
orderBy: "startTime",
|
||||
});
|
||||
return events.data.items;
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
export { getCalendarEntries };
|
||||
24
src/lib/util/logging/index.js
Normal file
24
src/lib/util/logging/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import pino from 'pino'
|
||||
import {logflarePinoVercel} from 'pino-logflare'
|
||||
|
||||
const {stream, send} = logflarePinoVercel({
|
||||
apiKey: "MK3qgU_-pwHQ",
|
||||
sourceToken: "f7d8c11d-8f36-4981-8168-bfd69aa72bbf"
|
||||
});
|
||||
|
||||
// create pino logger
|
||||
const logger = pino({
|
||||
browser: {
|
||||
transmit: {
|
||||
level: "info",
|
||||
send: send,
|
||||
}
|
||||
},
|
||||
level: "debug",
|
||||
base: {
|
||||
env: process.env.NODE_ENV,
|
||||
revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
|
||||
},
|
||||
}, stream);
|
||||
|
||||
export default logger
|
||||
16
src/lib/util/notifications/sms.ts
Normal file
16
src/lib/util/notifications/sms.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import logger from "@/lib/util/logging";
|
||||
|
||||
const sendSMS = async (number: string, body: string) => {
|
||||
const twilio = require("twilio");
|
||||
const client = require("twilio")(process.env.TWILIO_SID, process.env.TWILIO_AUTH_TOKEN);
|
||||
|
||||
const message = await client.messages
|
||||
.create({
|
||||
body: body,
|
||||
to: number, // Text this number
|
||||
from: process.env.TWILIO_FROM // From a valid Twilio number
|
||||
});
|
||||
logger.debug(`SMS sent to ${number}`, message);
|
||||
};
|
||||
|
||||
export { sendSMS };
|
||||
11
src/models/index.ts
Normal file
11
src/models/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import User from "./user";
|
||||
import Show from "./show";
|
||||
import Reminder from "./reminder";
|
||||
import Notification from "./notification";
|
||||
|
||||
export {
|
||||
User,
|
||||
Show,
|
||||
Reminder,
|
||||
Notification
|
||||
};
|
||||
17
src/models/notification.ts
Normal file
17
src/models/notification.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default class Notification {
|
||||
secondsBefore: number;
|
||||
destination: string;
|
||||
|
||||
constructor(secondsBefore: number,
|
||||
destination: string) {
|
||||
this.secondsBefore = secondsBefore;
|
||||
this.destination = destination;
|
||||
}
|
||||
|
||||
static fromJson(r: any): Notification {
|
||||
return new Notification(
|
||||
r.secondsBefore,
|
||||
r.destination
|
||||
);
|
||||
}
|
||||
};
|
||||
26
src/models/reminder.ts
Normal file
26
src/models/reminder.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import Notification from "./notification";
|
||||
|
||||
export default class Reminder {
|
||||
userId: string;
|
||||
showId: string;
|
||||
notifications: Notification[];
|
||||
created: Date;
|
||||
|
||||
constructor(userId: string,
|
||||
showId: string,
|
||||
notifications: [],
|
||||
created: Date) {
|
||||
this.userId = userId;
|
||||
this.showId = showId;
|
||||
this.notifications = notifications.map(n => Notification.fromJson(n));
|
||||
this.created = created;
|
||||
}
|
||||
|
||||
static fromJson(r: any): Reminder {
|
||||
return new Reminder(
|
||||
r.userId,
|
||||
r.showId,
|
||||
r.notifications,
|
||||
r.created);
|
||||
}
|
||||
};
|
||||
21
src/models/show.ts
Normal file
21
src/models/show.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export default class Show {
|
||||
id: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
creator: string;
|
||||
|
||||
constructor(id: string, title: string, date: string, creator: string) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.date = new Date(date);
|
||||
this.creator = creator;
|
||||
}
|
||||
|
||||
static fromJson(r: any): Show {
|
||||
return new Show(
|
||||
r.id,
|
||||
r.title,
|
||||
r.date,
|
||||
r.creator);
|
||||
}
|
||||
};
|
||||
9
src/models/user.ts
Normal file
9
src/models/user.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class User {
|
||||
uid: string;
|
||||
email: string
|
||||
|
||||
constructor(uid: string, email: string) {
|
||||
this.uid = uid;
|
||||
this.email = email;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,29 @@
|
||||
import {NextApiRequest, NextApiResponse} from "next";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getCalendarEntries } from "@/lib/util/google/calendarReader";
|
||||
import { shows } from "@/lib/db";
|
||||
import { Show } from "@/models";
|
||||
import logger from "@/lib/util/logging";
|
||||
import { doc, setDoc } from "@firebase/firestore";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).json({hello: "sailor"});
|
||||
try {
|
||||
const entries = await getCalendarEntries();
|
||||
const shows = entries.map((r: any) => Show.fromJson(r));
|
||||
for (const show of shows) {
|
||||
logger.debug("Storing show", show);
|
||||
const showRef = doc(shows, show.id);
|
||||
await setDoc(showRef, {
|
||||
title: show.title,
|
||||
date: show.date,
|
||||
creator: show.creator
|
||||
}, { merge: true });
|
||||
}
|
||||
logger.debug("Stored show", res);
|
||||
res.status(200);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.status(500);
|
||||
}
|
||||
res.end();
|
||||
};
|
||||
export default handler;
|
||||
|
||||
43
src/pages/api/cron/reminders.ts
Normal file
43
src/pages/api/cron/reminders.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { shows, reminders } from "@/lib/db";
|
||||
import { addSeconds, dateDifferenceInSeconds } from "@/lib/util/dateUtils";
|
||||
import { Notification, Reminder, Show } from "@/models";
|
||||
import { sendSMS } from "@/lib/util/notifications/sms";
|
||||
import { doc, getDocs, query, where } from "@firebase/firestore";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
//this handler is called by whatever CRON mechanism we're using
|
||||
//it will run every 5 minutes to check
|
||||
// if any shows are coming up and send out reminders.
|
||||
|
||||
//get all the shows that are on today
|
||||
const upcoming = await getDocs(query(shows, where("date", ">", new Date())));
|
||||
// const shows = await db.shows;
|
||||
// .where("date", ">", new Date())
|
||||
// .where("date", "<", addSeconds(new Date(), 60 * 60))
|
||||
// .get()
|
||||
// ;
|
||||
|
||||
for (const s of upcoming.docs) {
|
||||
const show = s.data() as Show;
|
||||
//load all the reminders for this show
|
||||
const activeReminders = await getDocs(query(reminders, where("showId", "==", show.id)));
|
||||
|
||||
//this runs every 5 minutes so any shows that have a reminder
|
||||
//due in the next 5 minutes should get queued
|
||||
for (const r of activeReminders.docs) {
|
||||
const reminder = r.data() as Reminder;
|
||||
for (let n in reminder.notifications) {
|
||||
const notification = Notification.fromJson(n);
|
||||
const targetDate = addSeconds(new Date(), notification.secondsBefore * -1);
|
||||
if (dateDifferenceInSeconds(targetDate, new Date(show.date)) <= 5 * 60) {
|
||||
//time to fire off a notification
|
||||
await sendSMS("353868065119", "New show starting in 1 hour");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.status(200);
|
||||
res.end();
|
||||
};
|
||||
export default handler;
|
||||
6
src/pages/api/cron/shows.ts
Normal file
6
src/pages/api/cron/shows.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.json({ ping: "ppong" });
|
||||
};
|
||||
export default handler;
|
||||
26
src/pages/api/reminders/index.ts
Normal file
26
src/pages/api/reminders/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import db from "@/lib/db";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === "POST") {
|
||||
const { userId, showId } = req.body;
|
||||
const docKey = `${userId}_${showId}`;
|
||||
const remindersRef = db.collection("reminders");
|
||||
const dbShow = remindersRef.doc(docKey);
|
||||
|
||||
const reminder = await remindersRef.doc(docKey).set({
|
||||
userId,
|
||||
showId,
|
||||
created: new Date(),
|
||||
notifications: [
|
||||
{ secondsBefore: 60 * 60, destination: "353868065119" } //just set a single reminder for an hour beforehand
|
||||
]
|
||||
}, { merge: true });
|
||||
res.status(201).json(reminder);
|
||||
} else {
|
||||
res.status(405);
|
||||
}
|
||||
res.end();
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,58 +1,18 @@
|
||||
// https://www.googleapis.com/calendar/v3/calendars/calendarId/events
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import db from "@/lib/db";
|
||||
|
||||
import {NextApiRequest, NextApiResponse} from "next";
|
||||
|
||||
const {google} = require("googleapis");
|
||||
const GOOGLE_PRIVATE_KEY = process.env.GOOGLE_CALENDAR_CREDENTIALS_PRIVATE_KEY;
|
||||
const GOOGLE_CLIENT_EMAIL =
|
||||
process.env.GOOGLE_CALENDAR_CREDENTIALS_CLIENT_EMAIL;
|
||||
const GOOGLE_PROJECT_NUMBER = process.env.GOOGLE_CALENDAR_PROJECT_ID;
|
||||
const GOOGLE_CALENDAR_ID = process.env.GOOGLE_CALENDAR_ID;
|
||||
const SCOPES = ["https://www.googleapis.com/auth/calendar"];
|
||||
|
||||
const jwtClient = new google.auth.JWT(
|
||||
GOOGLE_CLIENT_EMAIL,
|
||||
null,
|
||||
GOOGLE_PRIVATE_KEY,
|
||||
SCOPES
|
||||
);
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
project: GOOGLE_PROJECT_NUMBER,
|
||||
auth: jwtClient,
|
||||
});
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
calendar.events.list(
|
||||
{
|
||||
calendarId: GOOGLE_CALENDAR_ID,
|
||||
timeMin: new Date().toISOString(),
|
||||
maxResults: 10,
|
||||
singleEvents: true,
|
||||
orderBy: "startTime",
|
||||
},
|
||||
(error: any, result: any) => {
|
||||
if (error) {
|
||||
res.status(200).json({error: error});
|
||||
} else {
|
||||
if (result.data.items.length) {
|
||||
res.status(200).json(
|
||||
result.data.items.map((r: any) => {
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.summary,
|
||||
date: r.start.dateTime,
|
||||
};
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.status(200).json({
|
||||
events: [],
|
||||
message: "No upcoming events.",
|
||||
});
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
}
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const shows = await db
|
||||
.collection("shows")
|
||||
.orderBy("date")
|
||||
.get();
|
||||
res.status(200).json(shows.docs.map(show => {
|
||||
const result = {
|
||||
id: show.id,
|
||||
...show.data()
|
||||
};
|
||||
return result;
|
||||
}));
|
||||
res.end();
|
||||
};
|
||||
export default handler;
|
||||
|
||||
@@ -4,5 +4,10 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
// safelist: [
|
||||
// {
|
||||
// pattern: /./, // the "." means "everything"
|
||||
// },
|
||||
// ],
|
||||
plugins: [require("daisyui")],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user