diff --git a/components/Settings.js b/components/Settings.js
index 267f3d60..e5432f4d 100644
--- a/components/Settings.js
+++ b/components/Settings.js
@@ -8,13 +8,15 @@ import Trash from 'assets/trash.svg';
import Plus from 'assets/plus.svg';
import { get } from 'lib/web';
import Modal from './common/Modal';
-import WebsiteForm from './forms/WebsiteForm';
+import WebsiteEditForm from './forms/WebsiteEditForm';
import styles from './Settings.module.css';
+import WebsiteDeleteForm from './forms/WebsiteDeleteForm';
export default function Settings() {
const [data, setData] = useState();
const [edit, setEdit] = useState();
const [del, setDelete] = useState();
+ const [add, setAdd] = useState();
const [saved, setSaved] = useState(0);
const columns = [
@@ -44,6 +46,7 @@ export default function Settings() {
}
function handleClose() {
+ setAdd(null);
setEdit(null);
setDelete(null);
}
@@ -64,14 +67,28 @@ export default function Settings() {
Websites
- } size="S">
+ } size="S" onClick={() => setAdd(true)}>
Add website
{edit && (
-
+
+
+ )}
+ {add && (
+
+
+
+ )}
+ {del && (
+
+
)}
diff --git a/components/common/DropDown.js b/components/common/DropDown.js
index c3d6f852..f74fabff 100644
--- a/components/common/DropDown.js
+++ b/components/common/DropDown.js
@@ -36,7 +36,7 @@ export default function DropDown({
{options.find(e => e.value === value)?.label}
- } size="S" className={styles.icon} />
+ } size="S" />
{showMenu &&
}
diff --git a/components/common/Dropdown.module.css b/components/common/Dropdown.module.css
index e78d544a..ec63552f 100644
--- a/components/common/Dropdown.module.css
+++ b/components/common/Dropdown.module.css
@@ -5,18 +5,12 @@
}
.value {
+ display: flex;
+ justify-content: space-between;
white-space: nowrap;
position: relative;
- padding: 4px 32px 4px 16px;
+ padding: 4px 16px;
border: 1px solid var(--gray500);
border-radius: 4px;
cursor: pointer;
}
-
-.icon {
- position: absolute;
- top: 0;
- bottom: 0;
- right: 12px;
- margin: auto;
-}
diff --git a/components/common/Modal.module.css b/components/common/Modal.module.css
index 450b5820..74aa67b9 100644
--- a/components/common/Modal.module.css
+++ b/components/common/Modal.module.css
@@ -31,7 +31,6 @@
border: 1px solid var(--gray300);
padding: 30px;
border-radius: 4px;
- overflow: hidden;
}
.header {
diff --git a/components/forms/LoginForm.js b/components/forms/LoginForm.js
index 395319bb..0991c5eb 100644
--- a/components/forms/LoginForm.js
+++ b/components/forms/LoginForm.js
@@ -56,7 +56,7 @@ export default function LoginForm() {
-
diff --git a/components/forms/WebsiteDeleteForm.js b/components/forms/WebsiteDeleteForm.js
new file mode 100644
index 00000000..37d4de79
--- /dev/null
+++ b/components/forms/WebsiteDeleteForm.js
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import { Formik, Form, Field } from 'formik';
+import { del } from 'lib/web';
+import Button from 'components/interface/Button';
+import FormLayout, {
+ FormButtons,
+ FormError,
+ FormMessage,
+ FormRow,
+} from 'components/layout/FormLayout';
+
+const validate = ({ confirmation }) => {
+ const errors = {};
+
+ if (confirmation !== 'DELETE') {
+ errors.confirmation = !confirmation ? 'Required' : 'Invalid';
+ }
+
+ return errors;
+};
+
+export default function WebsiteDeleteForm({ initialValues, onSave, onClose }) {
+ const [message, setMessage] = useState();
+
+ const handleSubmit = async ({ website_id }) => {
+ const response = await del(`/api/website/${website_id}`);
+
+ if (response) {
+ onSave();
+ } else {
+ setMessage('Something went wrong.');
+ }
+ };
+
+ return (
+
+
+ {() => (
+
+ )}
+
+
+ );
+}
diff --git a/components/forms/WebsiteForm.js b/components/forms/WebsiteEditForm.js
similarity index 89%
rename from components/forms/WebsiteForm.js
rename to components/forms/WebsiteEditForm.js
index b131b93c..a287947a 100644
--- a/components/forms/WebsiteForm.js
+++ b/components/forms/WebsiteEditForm.js
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Formik, Form, Field } from 'formik';
-import Router from 'next/router';
import { post } from 'lib/web';
import Button from 'components/interface/Button';
import FormLayout, {
@@ -23,7 +22,7 @@ const validate = ({ name, domain }) => {
return errors;
};
-export default function WebsiteForm({ initialValues, onSave, onClose }) {
+export default function WebsiteEditForm({ initialValues, onSave, onClose }) {
const [message, setMessage] = useState();
const handleSubmit = async values => {
@@ -52,7 +51,9 @@ export default function WebsiteForm({ initialValues, onSave, onClose }) {
- Save
+
+ Save
+
Cancel
{message}
diff --git a/components/interface/Button.js b/components/interface/Button.js
index 3900dc3d..73513d91 100644
--- a/components/interface/Button.js
+++ b/components/interface/Button.js
@@ -7,6 +7,7 @@ export default function Button({
type = 'button',
icon,
size,
+ variant,
children,
className,
onClick = () => {},
@@ -17,6 +18,8 @@ export default function Button({
className={classNames(styles.button, className, {
[styles.small]: size === 'S',
[styles.large]: size === 'L',
+ [styles.action]: variant === 'action',
+ [styles.danger]: variant === 'danger',
})}
onClick={onClick}
>
diff --git a/components/interface/Button.module.css b/components/interface/Button.module.css
index fc46c02d..d6e6602c 100644
--- a/components/interface/Button.module.css
+++ b/components/interface/Button.module.css
@@ -26,3 +26,21 @@
.large {
font-size: var(--font-size-large);
}
+
+.action {
+ color: var(--gray50) !important;
+ background: var(--gray900) !important;
+}
+
+.action:hover {
+ background: var(--gray800) !important;
+}
+
+.danger {
+ color: var(--gray50) !important;
+ background: var(--red500) !important;
+}
+
+.danger:hover {
+ background: var(--red400) !important;
+}
diff --git a/components/layout/FormLayout.js b/components/layout/FormLayout.js
index 28c5f2d5..972b9273 100644
--- a/components/layout/FormLayout.js
+++ b/components/layout/FormLayout.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import { ErrorMessage } from 'formik';
import styles from './FormLayout.module.css';
@@ -11,9 +12,19 @@ export const FormButtons = ({ className, children }) => (
{children}
);
-export const FormError = ({ name }) => (
- {msg => {msg}
}
-);
+export const FormError = ({ name }) => {
+ return {msg => };
+};
+
+const ErrorTag = ({ msg }) => {
+ const props = useSpring({ opacity: 1, from: { opacity: 0 } });
+
+ return (
+
+ {msg}
+
+ );
+};
export const FormRow = ({ children }) => {children}
;
diff --git a/components/layout/FormLayout.module.css b/components/layout/FormLayout.module.css
index 9bf5a3de..f2b91596 100644
--- a/components/layout/FormLayout.module.css
+++ b/components/layout/FormLayout.module.css
@@ -20,11 +20,12 @@
.buttons {
display: flex;
justify-content: center;
+ margin-top: 20px;
}
.error {
color: var(--gray50);
- background: var(--color-error);
+ background: var(--red400);
font-size: var(--font-size-small);
position: absolute;
display: flex;
@@ -47,7 +48,7 @@
margin: auto;
width: 10px;
height: 10px;
- background: var(--color-error);
+ background: var(--red400);
transform: rotate(45deg);
}
diff --git a/lib/auth.js b/lib/auth.js
index 034626ad..b9344448 100644
--- a/lib/auth.js
+++ b/lib/auth.js
@@ -1,9 +1,9 @@
import { parse } from 'cookie';
-import { verifySecureToken } from './crypto';
+import { parseSecureToken } from './crypto';
import { AUTH_COOKIE_NAME } from './constants';
export async function verifyAuthToken(req) {
const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME];
- return verifySecureToken(token);
+ return parseSecureToken(token);
}
diff --git a/lib/crypto.js b/lib/crypto.js
index 0041adfa..dbd578bd 100644
--- a/lib/crypto.js
+++ b/lib/crypto.js
@@ -1,9 +1,8 @@
import crypto from 'crypto';
-import { v5 } from 'uuid';
+import { v4, v5, validate } from 'uuid';
import bcrypt from 'bcrypt';
import { JWT, JWE, JWK } from 'jose';
-const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const KEY = JWK.asKey(Buffer.from(secret()));
export function hash(...args) {
@@ -15,11 +14,13 @@ export function secret() {
}
export function uuid(...args) {
+ if (!args.length) return v4();
+
return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS));
}
export function isValidId(s) {
- return UUID_REGEX.test(s);
+ return validate(s);
}
export function checkPassword(password, hash) {
@@ -30,15 +31,24 @@ export async function createToken(payload) {
return JWT.sign(payload, KEY);
}
-export async function verifyToken(token) {
- return JWT.verify(token, KEY);
+export async function parseToken(token) {
+ try {
+ return JWT.verify(token, KEY);
+ } catch {
+ return null;
+ }
}
export async function createSecureToken(payload) {
return JWE.encrypt(await createToken(payload), KEY);
}
-export async function verifySecureToken(token) {
- const result = await JWE.decrypt(token, KEY);
- return verifyToken(result.toString());
+export async function parseSecureToken(token) {
+ try {
+ const result = await JWE.decrypt(token, KEY);
+
+ return parseToken(result.toString());
+ } catch {
+ return null;
+ }
}
diff --git a/lib/db.js b/lib/db.js
index b047b866..515877f2 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -63,6 +63,21 @@ export async function getWebsites(user_id) {
);
}
+export async function createWebsite(user_id, data) {
+ return runQuery(
+ prisma.website.create({
+ data: {
+ account: {
+ connect: {
+ user_id,
+ },
+ },
+ ...data,
+ },
+ }),
+ );
+}
+
export async function updateWebsite(website_id, data) {
return runQuery(
prisma.website.update({
@@ -74,6 +89,19 @@ export async function updateWebsite(website_id, data) {
);
}
+export async function deleteWebsite(website_id) {
+ return runQuery(
+ /* Prisma bug, does not cascade on non-nullable foreign keys
+ prisma.website.delete({
+ where: {
+ website_id,
+ },
+ }),
+ */
+ prisma.queryRaw(`delete from website where website_id=$1`, website_id),
+ );
+}
+
export async function createSession(website_id, data) {
return runQuery(
prisma.session.create({
diff --git a/lib/middleware.js b/lib/middleware.js
index b0260345..1694a67a 100644
--- a/lib/middleware.js
+++ b/lib/middleware.js
@@ -17,19 +17,23 @@ export function use(middleware) {
export const useCors = use(cors());
export const useSession = use(async (req, res, next) => {
- try {
- req.session = await verifySession(req);
- } catch {
+ const session = await verifySession(req);
+
+ if (!session) {
return res.status(400).end();
}
+
+ req.session = session;
next();
});
export const useAuth = use(async (req, res, next) => {
- try {
- req.auth = await verifyAuthToken(req);
- } catch {
+ const token = await verifyAuthToken(req);
+
+ if (!token) {
return res.status(401).end();
}
+
+ req.auth = token;
next();
});
diff --git a/lib/response.js b/lib/response.js
new file mode 100644
index 00000000..54df0f6c
--- /dev/null
+++ b/lib/response.js
@@ -0,0 +1,25 @@
+export function ok(res, data = {}) {
+ return res.status(200).json(data);
+}
+
+export function redirect(res, url) {
+ res.setHeader('Location', url);
+
+ return res.status(303).end();
+}
+
+export function badRequest(res) {
+ return res.status(400).end();
+}
+
+export function unauthorized(res) {
+ return res.status(401).end();
+}
+
+export function forbidden(res) {
+ return res.status(403).end();
+}
+
+export function methodNotAllowed(res) {
+ res.status(405).end();
+}
diff --git a/lib/session.js b/lib/session.js
index ca8bb697..9cdb1c7c 100644
--- a/lib/session.js
+++ b/lib/session.js
@@ -1,18 +1,14 @@
import { getWebsite, getSession, createSession } from 'lib/db';
import { getClientInfo } from 'lib/request';
-import { uuid, isValidId, verifyToken } from 'lib/crypto';
+import { uuid, isValidId, parseToken } from 'lib/crypto';
export async function verifySession(req) {
const { payload } = req.body;
const { website: website_uuid, hostname, screen, language, session } = payload;
- if (!isValidId(website_uuid)) {
- throw new Error(`Invalid website: ${website_uuid}`);
- }
+ const token = await parseToken(session);
- try {
- return await verifyToken(session);
- } catch {
+ if (!token || !isValidId(website_uuid) || token.website_uuid !== website_uuid) {
const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload);
if (website_uuid) {
@@ -50,4 +46,6 @@ export async function verifySession(req) {
}
}
}
+
+ return token;
}
diff --git a/lib/web.js b/lib/web.js
index d3430600..ad9db7c8 100644
--- a/lib/web.js
+++ b/lib/web.js
@@ -26,9 +26,11 @@ function parseQuery(url, params = {}) {
export const get = (url, params) => apiRequest('get', parseQuery(url, params));
+export const del = (url, params) => apiRequest('delete', parseQuery(url, params));
+
export const post = (url, params) => apiRequest('post', url, JSON.stringify(params));
-export const del = (url, params) => apiRequest('del', parseQuery(url, params));
+export const put = (url, params) => apiRequest('put', url, JSON.stringify(params));
export const hook = (_this, method, callback) => {
const orig = _this[method];
diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js
index 24ec8d9d..89833a9b 100644
--- a/pages/api/auth/login.js
+++ b/pages/api/auth/login.js
@@ -2,6 +2,7 @@ import { serialize } from 'cookie';
import { checkPassword, createSecureToken } from 'lib/crypto';
import { getAccount } from 'lib/db';
import { AUTH_COOKIE_NAME } from 'lib/constants';
+import { ok, unauthorized } from 'lib/response';
export default async (req, res) => {
const { username, password } = req.body;
@@ -19,8 +20,8 @@ export default async (req, res) => {
res.setHeader('Set-Cookie', [cookie]);
- return res.status(200).json({ token });
+ return ok(res, { token });
}
- return res.status(401).end();
+ return unauthorized(res);
};
diff --git a/pages/api/auth/logout.js b/pages/api/auth/logout.js
index fc9cd5ba..a78152f7 100644
--- a/pages/api/auth/logout.js
+++ b/pages/api/auth/logout.js
@@ -1,5 +1,6 @@
import { serialize } from 'cookie';
import { AUTH_COOKIE_NAME } from 'lib/constants';
+import { redirect } from 'lib/response';
export default async (req, res) => {
const cookie = serialize(AUTH_COOKIE_NAME, '', {
@@ -8,9 +9,7 @@ export default async (req, res) => {
maxAge: 0,
});
- res.statusCode = 303;
res.setHeader('Set-Cookie', [cookie]);
- res.setHeader('Location', '/login');
- return res.end();
+ return redirect(res, '/login');
};
diff --git a/pages/api/auth/verify.js b/pages/api/auth/verify.js
index e0f503b7..d9a2bb01 100644
--- a/pages/api/auth/verify.js
+++ b/pages/api/auth/verify.js
@@ -1,11 +1,12 @@
import { useAuth } from 'lib/middleware';
+import { ok, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
if (req.auth) {
- return res.status(200).json(req.auth);
+ return ok(res, req.auth);
}
- return res.status(401).end();
+ return unauthorized(res);
};
diff --git a/pages/api/collect.js b/pages/api/collect.js
index 325b45d5..86d25b63 100644
--- a/pages/api/collect.js
+++ b/pages/api/collect.js
@@ -1,6 +1,7 @@
import { savePageView, saveEvent } from 'lib/db';
import { useCors, useSession } from 'lib/middleware';
import { createToken } from 'lib/crypto';
+import { ok, badRequest } from 'lib/response';
export default async (req, res) => {
await useCors(req, res);
@@ -10,21 +11,18 @@ export default async (req, res) => {
const token = await createToken(session);
const { website_id, session_id } = session;
const { type, payload } = req.body;
- let ok = false;
if (type === 'pageview') {
const { url, referrer } = payload;
await savePageView(website_id, session_id, url, referrer);
-
- ok = true;
} else if (type === 'event') {
const { url, event_type, event_value } = payload;
await saveEvent(website_id, session_id, url, event_type, event_value);
-
- ok = true;
+ } else {
+ return badRequest(res);
}
- return res.status(200).json({ ok, session: token });
+ return ok(res, { session: token });
};
diff --git a/pages/api/user.js b/pages/api/user.js
index 1af56731..2b5017be 100644
--- a/pages/api/user.js
+++ b/pages/api/user.js
@@ -1,12 +1,13 @@
-import { verifySecureToken } from 'lib/crypto';
+import { parseSecureToken } from 'lib/crypto';
+import { ok, badRequest } from 'lib/response';
export default async (req, res) => {
const { token } = req.body;
try {
- const payload = await verifySecureToken(token);
- return res.status(200).json(payload);
+ const payload = await parseSecureToken(token);
+ return ok(res, payload);
} catch {
- return res.status(400).end();
+ return badRequest(res);
}
};
diff --git a/pages/api/website.js b/pages/api/website.js
index e308c65e..910d613c 100644
--- a/pages/api/website.js
+++ b/pages/api/website.js
@@ -1,26 +1,40 @@
-import { getWebsites, updateWebsite } from 'lib/db';
+import { getWebsites, updateWebsite, createWebsite, getWebsite } from 'lib/db';
import { useAuth } from 'lib/middleware';
+import { uuid } from 'lib/crypto';
+import { ok, unauthorized, methodNotAllowed } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
- const { user_id } = req.auth;
+ const { user_id, is_admin } = req.auth;
const { website_id } = req.body;
if (req.method === 'GET') {
const websites = await getWebsites(user_id);
- return res.status(200).json(websites);
+ return ok(res, websites);
}
if (req.method === 'POST') {
- if (website_id) {
- const { name, domain } = req.body;
- const website = await updateWebsite(website_id, { name, domain });
+ const { name, domain } = req.body;
- return res.status(200).json(website);
+ if (website_id) {
+ const website = getWebsite(website_id);
+
+ if (website.user_id === user_id || is_admin) {
+ await updateWebsite(website_id, { name, domain });
+
+ return ok(res);
+ }
+
+ return unauthorized(res);
+ } else {
+ const website_uuid = uuid();
+ const website = await createWebsite(user_id, { website_uuid, name, domain });
+
+ return ok(res, website);
}
}
- return res.status(405).end();
+ return methodNotAllowed(res);
};
diff --git a/pages/api/website/[id]/index.js b/pages/api/website/[id]/index.js
index cb39c63e..ff1c219b 100644
--- a/pages/api/website/[id]/index.js
+++ b/pages/api/website/[id]/index.js
@@ -1,12 +1,31 @@
-import { getWebsite } from 'lib/db';
+import { deleteWebsite, getWebsite } from 'lib/db';
import { useAuth } from 'lib/middleware';
+import { methodNotAllowed, ok, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
+ const { user_id, is_admin } = req.auth;
const { id } = req.query;
+ const website_id = +id;
- const website = await getWebsite({ website_id: +id });
+ if (req.method === 'GET') {
+ const website = await getWebsite({ website_id });
- return res.status(200).json(website);
+ return ok(res, website);
+ }
+
+ if (req.method === 'DELETE') {
+ const website = await getWebsite({ website_id });
+
+ if (website.user_id === user_id || is_admin) {
+ await deleteWebsite(website_id);
+
+ return ok(res);
+ }
+
+ return unauthorized(res);
+ }
+
+ return methodNotAllowed(res);
};
diff --git a/pages/api/website/[id]/metrics.js b/pages/api/website/[id]/metrics.js
index 234336b0..62ef6afc 100644
--- a/pages/api/website/[id]/metrics.js
+++ b/pages/api/website/[id]/metrics.js
@@ -1,5 +1,6 @@
import { getMetrics } from 'lib/db';
import { useAuth } from 'lib/middleware';
+import { ok } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
@@ -17,5 +18,5 @@ export default async (req, res) => {
return obj;
}, {});
- return res.status(200).json(stats);
+ return ok(res, stats);
};
diff --git a/pages/api/website/[id]/pageviews.js b/pages/api/website/[id]/pageviews.js
index a2a2fbab..68311e3e 100644
--- a/pages/api/website/[id]/pageviews.js
+++ b/pages/api/website/[id]/pageviews.js
@@ -1,6 +1,7 @@
import moment from 'moment-timezone';
import { getPageviewData } from 'lib/db';
import { useAuth } from 'lib/middleware';
+import { ok, badRequest } from 'lib/response';
const unitTypes = ['month', 'hour', 'day'];
@@ -10,7 +11,7 @@ export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
- return res.status(400).end();
+ return badRequest(res);
}
const start = new Date(+start_at);
@@ -21,5 +22,5 @@ export default async (req, res) => {
getPageviewData(+id, start, end, tz, unit, 'distinct session_id'),
]);
- return res.status(200).json({ pageviews, uniques });
+ return ok(res, { pageviews, uniques });
};
diff --git a/pages/api/website/[id]/rankings.js b/pages/api/website/[id]/rankings.js
index 4be955cf..aa58b359 100644
--- a/pages/api/website/[id]/rankings.js
+++ b/pages/api/website/[id]/rankings.js
@@ -1,5 +1,6 @@
import { getRankings } from 'lib/db';
import { useAuth } from 'lib/middleware';
+import { ok, badRequest } from 'lib/response';
const sessionColumns = ['browser', 'os', 'device', 'country'];
const pageviewColumns = ['url', 'referrer'];
@@ -10,12 +11,12 @@ export default async (req, res) => {
const { id, type, start_at, end_at } = req.query;
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
- return res.status(400).end();
+ return badRequest(res);
}
const table = sessionColumns.includes(type) ? 'session' : 'pageview';
const rankings = await getRankings(+id, new Date(+start_at), new Date(+end_at), type, table);
- return res.status(200).json(rankings);
+ return ok(res, rankings);
};
diff --git a/pages/test.js b/pages/test.js
index 3dcc834c..30c00e70 100644
--- a/pages/test.js
+++ b/pages/test.js
@@ -1,13 +1,21 @@
import Head from 'next/head';
import Link from 'next/link';
+import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout';
-export default function Test({ websiteId }) {
+export default function Test() {
+ const router = useRouter();
+ const { id } = router.query;
+
+ if (!id) {
+ return No id query specified.
;
+ }
+
return (
<>
{typeof window !== 'undefined' && (
-
+
)}
@@ -17,17 +25,17 @@ export default function Test({ websiteId }) {
trigger page views. Clicking on the button should trigger an event.
Page links
-
+
Page One
-
+
Page Two
Events
Button
@@ -36,11 +44,3 @@ export default function Test({ websiteId }) {
>
);
}
-
-export async function getStaticProps() {
- return {
- props: {
- websiteId: process.env.TEST_WEBSITE_ID,
- },
- };
-}
diff --git a/sql/schema.postgresql.sql b/sql/schema.postgresql.sql
index 776e5049..f319ce87 100644
--- a/sql/schema.postgresql.sql
+++ b/sql/schema.postgresql.sql
@@ -32,7 +32,7 @@ create table session (
create table pageview (
view_id serial primary key,
- website_id int not null references website(website_id),
+ website_id int not null references website(website_id) on delete cascade,
session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,
@@ -41,7 +41,7 @@ create table pageview (
create table event (
event_id serial primary key,
- website_id int not null references website(website_id),
+ website_id int not null references website(website_id) on delete cascade,
session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,
diff --git a/styles/variables.css b/styles/variables.css
index 7f4d7d76..7a628d9f 100644
--- a/styles/variables.css
+++ b/styles/variables.css
@@ -27,5 +27,8 @@
--grid-size-large: 992px;
--grid-size-xlarge: 1140px;
- --color-error: #e34850;
+ --red400: #e34850;
+ --red500: #d7373f;
+ --red600: #c9252d;
+ --red700: #bb121a;
}