Handle website delete. Added response helper functions.

This commit is contained in:
Mike Cao 2020-08-07 17:19:42 -07:00
parent 0a411a9ad6
commit c4b75e4aec
31 changed files with 314 additions and 96 deletions

View File

@ -8,13 +8,15 @@ import Trash from 'assets/trash.svg';
import Plus from 'assets/plus.svg'; import Plus from 'assets/plus.svg';
import { get } from 'lib/web'; import { get } from 'lib/web';
import Modal from './common/Modal'; import Modal from './common/Modal';
import WebsiteForm from './forms/WebsiteForm'; import WebsiteEditForm from './forms/WebsiteEditForm';
import styles from './Settings.module.css'; import styles from './Settings.module.css';
import WebsiteDeleteForm from './forms/WebsiteDeleteForm';
export default function Settings() { export default function Settings() {
const [data, setData] = useState(); const [data, setData] = useState();
const [edit, setEdit] = useState(); const [edit, setEdit] = useState();
const [del, setDelete] = useState(); const [del, setDelete] = useState();
const [add, setAdd] = useState();
const [saved, setSaved] = useState(0); const [saved, setSaved] = useState(0);
const columns = [ const columns = [
@ -44,6 +46,7 @@ export default function Settings() {
} }
function handleClose() { function handleClose() {
setAdd(null);
setEdit(null); setEdit(null);
setDelete(null); setDelete(null);
} }
@ -64,14 +67,28 @@ export default function Settings() {
<Page> <Page>
<PageHeader> <PageHeader>
<div>Websites</div> <div>Websites</div>
<Button icon={<Plus />} size="S"> <Button icon={<Plus />} size="S" onClick={() => setAdd(true)}>
<div>Add website</div> <div>Add website</div>
</Button> </Button>
</PageHeader> </PageHeader>
<Table columns={columns} rows={data} /> <Table columns={columns} rows={data} />
{edit && ( {edit && (
<Modal title="Edit website"> <Modal title="Edit website">
<WebsiteForm initialValues={edit} onSave={handleSave} onClose={handleClose} /> <WebsiteEditForm initialValues={edit} onSave={handleSave} onClose={handleClose} />
</Modal>
)}
{add && (
<Modal title="Add website">
<WebsiteEditForm
initialValues={{ name: '', domain: '' }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{del && (
<Modal title="Delete website">
<WebsiteDeleteForm initialValues={del} onSave={handleSave} onClose={handleClose} />
</Modal> </Modal>
)} )}
</Page> </Page>

View File

@ -36,7 +36,7 @@ export default function DropDown({
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}> <div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}> <div className={styles.value}>
{options.find(e => e.value === value)?.label} {options.find(e => e.value === value)?.label}
<Icon icon={<Chevron />} size="S" className={styles.icon} /> <Icon icon={<Chevron />} size="S" />
</div> </div>
{showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />} {showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />}
</div> </div>

View File

@ -5,18 +5,12 @@
} }
.value { .value {
display: flex;
justify-content: space-between;
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
padding: 4px 32px 4px 16px; padding: 4px 16px;
border: 1px solid var(--gray500); border: 1px solid var(--gray500);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.icon {
position: absolute;
top: 0;
bottom: 0;
right: 12px;
margin: auto;
}

View File

@ -31,7 +31,6 @@
border: 1px solid var(--gray300); border: 1px solid var(--gray300);
padding: 30px; padding: 30px;
border-radius: 4px; border-radius: 4px;
overflow: hidden;
} }
.header { .header {

View File

@ -56,7 +56,7 @@ export default function LoginForm() {
<FormError name="password" /> <FormError name="password" />
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<Button className={styles.button} type="submit"> <Button type="submit" variant="action">
Login Login
</Button> </Button>
</FormButtons> </FormButtons>

View File

@ -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 (
<FormLayout>
<Formik
initialValues={{ confirmation: '', ...initialValues }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<div>
Are your sure you want to delete <b>{initialValues.name}</b>?
</div>
<div>All associated data will be deleted as well.</div>
<p>
Type <b>DELETE</b> in the box below to confirm.
</p>
<FormRow>
<label htmlFor="confirmation">Confirm</label>
<Field name="confirmation" />
<FormError name="confirmation" />
</FormRow>
<FormButtons>
<Button type="submit" variant="danger">
Delete
</Button>
<Button onClick={onClose}>Cancel</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View File

@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Formik, Form, Field } from 'formik'; import { Formik, Form, Field } from 'formik';
import Router from 'next/router';
import { post } from 'lib/web'; import { post } from 'lib/web';
import Button from 'components/interface/Button'; import Button from 'components/interface/Button';
import FormLayout, { import FormLayout, {
@ -23,7 +22,7 @@ const validate = ({ name, domain }) => {
return errors; return errors;
}; };
export default function WebsiteForm({ initialValues, onSave, onClose }) { export default function WebsiteEditForm({ initialValues, onSave, onClose }) {
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const handleSubmit = async values => { const handleSubmit = async values => {
@ -52,7 +51,9 @@ export default function WebsiteForm({ initialValues, onSave, onClose }) {
<FormError name="domain" /> <FormError name="domain" />
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<Button type="submit">Save</Button> <Button type="submit" variant="action">
Save
</Button>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
</FormButtons> </FormButtons>
<FormMessage>{message}</FormMessage> <FormMessage>{message}</FormMessage>

View File

@ -7,6 +7,7 @@ export default function Button({
type = 'button', type = 'button',
icon, icon,
size, size,
variant,
children, children,
className, className,
onClick = () => {}, onClick = () => {},
@ -17,6 +18,8 @@ export default function Button({
className={classNames(styles.button, className, { className={classNames(styles.button, className, {
[styles.small]: size === 'S', [styles.small]: size === 'S',
[styles.large]: size === 'L', [styles.large]: size === 'L',
[styles.action]: variant === 'action',
[styles.danger]: variant === 'danger',
})} })}
onClick={onClick} onClick={onClick}
> >

View File

@ -26,3 +26,21 @@
.large { .large {
font-size: var(--font-size-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;
}

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames'; import classNames from 'classnames';
import { ErrorMessage } from 'formik'; import { ErrorMessage } from 'formik';
import styles from './FormLayout.module.css'; import styles from './FormLayout.module.css';
@ -11,9 +12,19 @@ export const FormButtons = ({ className, children }) => (
<div className={classNames(styles.buttons, className)}>{children}</div> <div className={classNames(styles.buttons, className)}>{children}</div>
); );
export const FormError = ({ name }) => ( export const FormError = ({ name }) => {
<ErrorMessage name={name}>{msg => <div className={styles.error}>{msg}</div>}</ErrorMessage> return <ErrorMessage name={name}>{msg => <ErrorTag msg={msg} />}</ErrorMessage>;
); };
const ErrorTag = ({ msg }) => {
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
return (
<animated.div className={styles.error} style={props}>
{msg}
</animated.div>
);
};
export const FormRow = ({ children }) => <div className={styles.row}>{children}</div>; export const FormRow = ({ children }) => <div className={styles.row}>{children}</div>;

View File

@ -20,11 +20,12 @@
.buttons { .buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 20px;
} }
.error { .error {
color: var(--gray50); color: var(--gray50);
background: var(--color-error); background: var(--red400);
font-size: var(--font-size-small); font-size: var(--font-size-small);
position: absolute; position: absolute;
display: flex; display: flex;
@ -47,7 +48,7 @@
margin: auto; margin: auto;
width: 10px; width: 10px;
height: 10px; height: 10px;
background: var(--color-error); background: var(--red400);
transform: rotate(45deg); transform: rotate(45deg);
} }

View File

@ -1,9 +1,9 @@
import { parse } from 'cookie'; import { parse } from 'cookie';
import { verifySecureToken } from './crypto'; import { parseSecureToken } from './crypto';
import { AUTH_COOKIE_NAME } from './constants'; import { AUTH_COOKIE_NAME } from './constants';
export async function verifyAuthToken(req) { export async function verifyAuthToken(req) {
const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME]; const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME];
return verifySecureToken(token); return parseSecureToken(token);
} }

View File

@ -1,9 +1,8 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { v5 } from 'uuid'; import { v4, v5, validate } from 'uuid';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { JWT, JWE, JWK } from 'jose'; 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())); const KEY = JWK.asKey(Buffer.from(secret()));
export function hash(...args) { export function hash(...args) {
@ -15,11 +14,13 @@ export function secret() {
} }
export function uuid(...args) { export function uuid(...args) {
if (!args.length) return v4();
return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS)); return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS));
} }
export function isValidId(s) { export function isValidId(s) {
return UUID_REGEX.test(s); return validate(s);
} }
export function checkPassword(password, hash) { export function checkPassword(password, hash) {
@ -30,15 +31,24 @@ export async function createToken(payload) {
return JWT.sign(payload, KEY); return JWT.sign(payload, KEY);
} }
export async function verifyToken(token) { export async function parseToken(token) {
return JWT.verify(token, KEY); try {
return JWT.verify(token, KEY);
} catch {
return null;
}
} }
export async function createSecureToken(payload) { export async function createSecureToken(payload) {
return JWE.encrypt(await createToken(payload), KEY); return JWE.encrypt(await createToken(payload), KEY);
} }
export async function verifySecureToken(token) { export async function parseSecureToken(token) {
const result = await JWE.decrypt(token, KEY); try {
return verifyToken(result.toString()); const result = await JWE.decrypt(token, KEY);
return parseToken(result.toString());
} catch {
return null;
}
} }

View File

@ -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) { export async function updateWebsite(website_id, data) {
return runQuery( return runQuery(
prisma.website.update({ 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) { export async function createSession(website_id, data) {
return runQuery( return runQuery(
prisma.session.create({ prisma.session.create({

View File

@ -17,19 +17,23 @@ export function use(middleware) {
export const useCors = use(cors()); export const useCors = use(cors());
export const useSession = use(async (req, res, next) => { export const useSession = use(async (req, res, next) => {
try { const session = await verifySession(req);
req.session = await verifySession(req);
} catch { if (!session) {
return res.status(400).end(); return res.status(400).end();
} }
req.session = session;
next(); next();
}); });
export const useAuth = use(async (req, res, next) => { export const useAuth = use(async (req, res, next) => {
try { const token = await verifyAuthToken(req);
req.auth = await verifyAuthToken(req);
} catch { if (!token) {
return res.status(401).end(); return res.status(401).end();
} }
req.auth = token;
next(); next();
}); });

25
lib/response.js Normal file
View File

@ -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();
}

View File

@ -1,18 +1,14 @@
import { getWebsite, getSession, createSession } from 'lib/db'; import { getWebsite, getSession, createSession } from 'lib/db';
import { getClientInfo } from 'lib/request'; import { getClientInfo } from 'lib/request';
import { uuid, isValidId, verifyToken } from 'lib/crypto'; import { uuid, isValidId, parseToken } from 'lib/crypto';
export async function verifySession(req) { export async function verifySession(req) {
const { payload } = req.body; const { payload } = req.body;
const { website: website_uuid, hostname, screen, language, session } = payload; const { website: website_uuid, hostname, screen, language, session } = payload;
if (!isValidId(website_uuid)) { const token = await parseToken(session);
throw new Error(`Invalid website: ${website_uuid}`);
}
try { if (!token || !isValidId(website_uuid) || token.website_uuid !== website_uuid) {
return await verifyToken(session);
} catch {
const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload); const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload);
if (website_uuid) { if (website_uuid) {
@ -50,4 +46,6 @@ export async function verifySession(req) {
} }
} }
} }
return token;
} }

View File

@ -26,9 +26,11 @@ function parseQuery(url, params = {}) {
export const get = (url, params) => apiRequest('get', 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 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) => { export const hook = (_this, method, callback) => {
const orig = _this[method]; const orig = _this[method];

View File

@ -2,6 +2,7 @@ import { serialize } from 'cookie';
import { checkPassword, createSecureToken } from 'lib/crypto'; import { checkPassword, createSecureToken } from 'lib/crypto';
import { getAccount } from 'lib/db'; import { getAccount } from 'lib/db';
import { AUTH_COOKIE_NAME } from 'lib/constants'; import { AUTH_COOKIE_NAME } from 'lib/constants';
import { ok, unauthorized } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
@ -19,8 +20,8 @@ export default async (req, res) => {
res.setHeader('Set-Cookie', [cookie]); res.setHeader('Set-Cookie', [cookie]);
return res.status(200).json({ token }); return ok(res, { token });
} }
return res.status(401).end(); return unauthorized(res);
}; };

View File

@ -1,5 +1,6 @@
import { serialize } from 'cookie'; import { serialize } from 'cookie';
import { AUTH_COOKIE_NAME } from 'lib/constants'; import { AUTH_COOKIE_NAME } from 'lib/constants';
import { redirect } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
const cookie = serialize(AUTH_COOKIE_NAME, '', { const cookie = serialize(AUTH_COOKIE_NAME, '', {
@ -8,9 +9,7 @@ export default async (req, res) => {
maxAge: 0, maxAge: 0,
}); });
res.statusCode = 303;
res.setHeader('Set-Cookie', [cookie]); res.setHeader('Set-Cookie', [cookie]);
res.setHeader('Location', '/login');
return res.end(); return redirect(res, '/login');
}; };

View File

@ -1,11 +1,12 @@
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, unauthorized } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
if (req.auth) { if (req.auth) {
return res.status(200).json(req.auth); return ok(res, req.auth);
} }
return res.status(401).end(); return unauthorized(res);
}; };

View File

@ -1,6 +1,7 @@
import { savePageView, saveEvent } from 'lib/db'; import { savePageView, saveEvent } from 'lib/db';
import { useCors, useSession } from 'lib/middleware'; import { useCors, useSession } from 'lib/middleware';
import { createToken } from 'lib/crypto'; import { createToken } from 'lib/crypto';
import { ok, badRequest } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useCors(req, res); await useCors(req, res);
@ -10,21 +11,18 @@ export default async (req, res) => {
const token = await createToken(session); const token = await createToken(session);
const { website_id, session_id } = session; const { website_id, session_id } = session;
const { type, payload } = req.body; const { type, payload } = req.body;
let ok = false;
if (type === 'pageview') { if (type === 'pageview') {
const { url, referrer } = payload; const { url, referrer } = payload;
await savePageView(website_id, session_id, url, referrer); await savePageView(website_id, session_id, url, referrer);
ok = true;
} else if (type === 'event') { } else if (type === 'event') {
const { url, event_type, event_value } = payload; const { url, event_type, event_value } = payload;
await saveEvent(website_id, session_id, url, event_type, event_value); await saveEvent(website_id, session_id, url, event_type, event_value);
} else {
ok = true; return badRequest(res);
} }
return res.status(200).json({ ok, session: token }); return ok(res, { session: token });
}; };

View File

@ -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) => { export default async (req, res) => {
const { token } = req.body; const { token } = req.body;
try { try {
const payload = await verifySecureToken(token); const payload = await parseSecureToken(token);
return res.status(200).json(payload); return ok(res, payload);
} catch { } catch {
return res.status(400).end(); return badRequest(res);
} }
}; };

View File

@ -1,26 +1,40 @@
import { getWebsites, updateWebsite } from 'lib/db'; import { getWebsites, updateWebsite, createWebsite, getWebsite } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { uuid } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
const { user_id } = req.auth; const { user_id, is_admin } = req.auth;
const { website_id } = req.body; const { website_id } = req.body;
if (req.method === 'GET') { if (req.method === 'GET') {
const websites = await getWebsites(user_id); const websites = await getWebsites(user_id);
return res.status(200).json(websites); return ok(res, websites);
} }
if (req.method === 'POST') { if (req.method === 'POST') {
if (website_id) { const { name, domain } = req.body;
const { name, domain } = req.body;
const website = await updateWebsite(website_id, { name, domain });
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);
}; };

View File

@ -1,12 +1,31 @@
import { getWebsite } from 'lib/db'; import { deleteWebsite, getWebsite } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
const { user_id, is_admin } = req.auth;
const { id } = req.query; 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);
}; };

View File

@ -1,5 +1,6 @@
import { getMetrics } from 'lib/db'; import { getMetrics } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
@ -17,5 +18,5 @@ export default async (req, res) => {
return obj; return obj;
}, {}); }, {});
return res.status(200).json(stats); return ok(res, stats);
}; };

View File

@ -1,6 +1,7 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { getPageviewData } from 'lib/db'; import { getPageviewData } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, badRequest } from 'lib/response';
const unitTypes = ['month', 'hour', 'day']; const unitTypes = ['month', 'hour', 'day'];
@ -10,7 +11,7 @@ export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query; const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) { if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return res.status(400).end(); return badRequest(res);
} }
const start = new Date(+start_at); const start = new Date(+start_at);
@ -21,5 +22,5 @@ export default async (req, res) => {
getPageviewData(+id, start, end, tz, unit, 'distinct session_id'), getPageviewData(+id, start, end, tz, unit, 'distinct session_id'),
]); ]);
return res.status(200).json({ pageviews, uniques }); return ok(res, { pageviews, uniques });
}; };

View File

@ -1,5 +1,6 @@
import { getRankings } from 'lib/db'; import { getRankings } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { ok, badRequest } from 'lib/response';
const sessionColumns = ['browser', 'os', 'device', 'country']; const sessionColumns = ['browser', 'os', 'device', 'country'];
const pageviewColumns = ['url', 'referrer']; const pageviewColumns = ['url', 'referrer'];
@ -10,12 +11,12 @@ export default async (req, res) => {
const { id, type, start_at, end_at } = req.query; const { id, type, start_at, end_at } = req.query;
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) { if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
return res.status(400).end(); return badRequest(res);
} }
const table = sessionColumns.includes(type) ? 'session' : 'pageview'; const table = sessionColumns.includes(type) ? 'session' : 'pageview';
const rankings = await getRankings(+id, new Date(+start_at), new Date(+end_at), type, table); 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);
}; };

View File

@ -1,13 +1,21 @@
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from 'components/layout/Layout'; 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 <h1>No id query specified.</h1>;
}
return ( return (
<> <>
<Head> <Head>
{typeof window !== 'undefined' && ( {typeof window !== 'undefined' && (
<script async defer data-website-id={websiteId} src="/umami.js" /> <script async defer data-website-id={id} src="/umami.js" />
)} )}
</Head> </Head>
<Layout> <Layout>
@ -17,17 +25,17 @@ export default function Test({ websiteId }) {
trigger page views. Clicking on the button should trigger an event. trigger page views. Clicking on the button should trigger an event.
</p> </p>
<h2>Page links</h2> <h2>Page links</h2>
<Link href="?q=1"> <Link href={`?id=${id}&q=1`}>
<a>Page One</a> <a>Page One</a>
</Link> </Link>
<br /> <br />
<Link href="?q=2"> <Link href={`?id=${id}&q=2`}>
<a>Page Two</a> <a>Page Two</a>
</Link> </Link>
<h2>Events</h2> <h2>Events</h2>
<button <button
id="primary-button" id="primary-button"
className="otherClass umami--click--primary-button" className="otherClass umami--click--primary-button align-self-start"
type="button" type="button"
> >
Button Button
@ -36,11 +44,3 @@ export default function Test({ websiteId }) {
</> </>
); );
} }
export async function getStaticProps() {
return {
props: {
websiteId: process.env.TEST_WEBSITE_ID,
},
};
}

View File

@ -32,7 +32,7 @@ create table session (
create table pageview ( create table pageview (
view_id serial primary key, 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, session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp, created_at timestamp with time zone default current_timestamp,
url varchar(500) not null, url varchar(500) not null,
@ -41,7 +41,7 @@ create table pageview (
create table event ( create table event (
event_id serial primary key, 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, session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp, created_at timestamp with time zone default current_timestamp,
url varchar(500) not null, url varchar(500) not null,

View File

@ -27,5 +27,8 @@
--grid-size-large: 992px; --grid-size-large: 992px;
--grid-size-xlarge: 1140px; --grid-size-xlarge: 1140px;
--color-error: #e34850; --red400: #e34850;
--red500: #d7373f;
--red600: #c9252d;
--red700: #bb121a;
} }