diff --git a/frontend/drizzle/0005_striped_prism.sql b/frontend/drizzle/0005_striped_prism.sql new file mode 100644 index 0000000..146618f --- /dev/null +++ b/frontend/drizzle/0005_striped_prism.sql @@ -0,0 +1,3 @@ +ALTER TABLE "visitor" ADD COLUMN "device_type" varchar(255);--> statement-breakpoint +ALTER TABLE "visitor" ADD COLUMN "device_vendor" varchar(255);--> statement-breakpoint +ALTER TABLE "visitor" ADD COLUMN "os" varchar(255); \ No newline at end of file diff --git a/frontend/drizzle/meta/0005_snapshot.json b/frontend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..0590d7b --- /dev/null +++ b/frontend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,274 @@ +{ + "id": "41b90ddc-497c-47fb-8870-16e1ad9b492b", + "prevId": "6fa088cf-5f05-40aa-bb38-94848c4feb92", + "version": "5", + "dialect": "pg", + "tables": { + "project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "setting": { + "name": "setting", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qr_background": { + "name": "qr_background", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "qr_foreground": { + "name": "qr_foreground", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "shortener": { + "name": "shortener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "visitor": { + "name": "visitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "shortener_id": { + "name": "shortener_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "country_code": { + "name": "country_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_vendor": { + "name": "device_vendor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index 79f7260..0ff4ed2 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1704723435338, "tag": "0004_cynical_the_hand", "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1704837856450, + "tag": "0005_striped_prism", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/src/lib/db/schema.ts b/frontend/src/lib/db/schema.ts index 3b688ba..bd1e113 100644 --- a/frontend/src/lib/db/schema.ts +++ b/frontend/src/lib/db/schema.ts @@ -77,6 +77,9 @@ export const visitor = pgTable('visitor', { }).notNull(), country: varchar('country', { length: 255 }).notNull(), city: varchar('city', { length: 255 }).notNull(), + deviceType: varchar('device_type', { length: 255 }), + deviceVendor: varchar('device_vendor', { length: 255 }), + os: varchar('os', { length: 255 }), }) export const visitorRelations = relations(visitor, ({ one }) => ({ diff --git a/frontend/src/routes/(app)/links/+page.svelte b/frontend/src/routes/(app)/links/+page.svelte index f6365d0..30c36d5 100644 --- a/frontend/src/routes/(app)/links/+page.svelte +++ b/frontend/src/routes/(app)/links/+page.svelte @@ -154,7 +154,7 @@ {#if data.shorteners.length > 0} -
+
{#each data.shorteners as shortener} diff --git a/frontend/src/routes/(app)/links/[id]/+page.server.ts b/frontend/src/routes/(app)/links/[id]/+page.server.ts index c086d3d..9cd55a3 100644 --- a/frontend/src/routes/(app)/links/[id]/+page.server.ts +++ b/frontend/src/routes/(app)/links/[id]/+page.server.ts @@ -1,7 +1,7 @@ import { db } from '$lib/db' import { redirect } from '@sveltejs/kit' import type { PageServerLoad } from './$types' -import { eq, sql } from 'drizzle-orm' +import { and, eq, sql } from 'drizzle-orm' import { visitor as visitorSchema } from '$lib/db/schema' export const load = (async (event) => { @@ -23,13 +23,22 @@ export const load = (async (event) => { throw redirect(303, '/') } + const now = new Date() + const visitor = await db .select({ count: sql`cast(count(*) as int)`, month: sql`cast(to_char(${visitorSchema.createdAt}, 'MM') as int)`, }) .from(visitorSchema) - .where(eq(visitorSchema.shortenerId, shortener.id)) + .where( + and( + eq(visitorSchema.shortenerId, shortener.id), + sql`to_char(${ + visitorSchema.createdAt + }, 'YYYY') = ${now.getFullYear()}`, + ), + ) .groupBy(sql`to_char(${visitorSchema.createdAt}, 'MM')`) const visitorByCountry = await db @@ -57,5 +66,40 @@ export const load = (async (event) => { visitorSchema.city, ) - return { shortener, visitor, visitorByCountry, visitorByCity } + const visitorByOS = await db + .select({ + count: sql`cast(count(*) as int)`, + os: visitorSchema.os, + }) + .from(visitorSchema) + .where(eq(visitorSchema.shortenerId, shortener.id)) + .groupBy(visitorSchema.os) + + const visitorByDeviceVendor = await db + .select({ + count: sql`cast(count(*) as int)`, + vendor: visitorSchema.deviceVendor, + }) + .from(visitorSchema) + .where(eq(visitorSchema.shortenerId, shortener.id)) + .groupBy(visitorSchema.deviceVendor) + + const visitorByDeviceType = await db + .select({ + count: sql`cast(count(*) as int)`, + type: visitorSchema.deviceType, + }) + .from(visitorSchema) + .where(eq(visitorSchema.shortenerId, shortener.id)) + .groupBy(visitorSchema.deviceType) + + return { + shortener, + visitor, + visitorByCountry, + visitorByCity, + visitorByOS, + visitorByDeviceVendor, + visitorByDeviceType, + } }) satisfies PageServerLoad diff --git a/frontend/src/routes/(app)/links/[id]/+page.svelte b/frontend/src/routes/(app)/links/[id]/+page.svelte index da94c0e..7ccdd35 100644 --- a/frontend/src/routes/(app)/links/[id]/+page.svelte +++ b/frontend/src/routes/(app)/links/[id]/+page.svelte @@ -6,6 +6,12 @@ import type { ApexOptions } from 'apexcharts' import { mode } from 'mode-watcher' import { onMount } from 'svelte' + import { + ShieldQuestion, + Smartphone, + Tablet, + TabletSmartphone, + } from 'lucide-svelte' export let data: PageData @@ -102,8 +108,8 @@
-
- +
+ Clicks
- + @@ -157,4 +163,73 @@ + + + +
+ Devices + Devices by Country/City +
+ + Vendor + Type + OS + +
+ + +
+ {#each data.visitorByDeviceVendor as visitorByDeviceVendor} +
+
+ +
+ {visitorByDeviceVendor.vendor ?? + 'Undefined Vendor'} +
+
+
{visitorByDeviceVendor.count}
+
+ {/each} +
+
+ +
+ {#each data.visitorByDeviceType as visitorByDeviceType} +
+
+ {#if visitorByDeviceType.type === 'mobile'} + + {:else if visitorByDeviceType.type === 'tablet'} + + {:else} + + {/if} +
+ {visitorByDeviceType.type ?? + 'Undefined Device Type'} +
+
+
{visitorByDeviceType.count}
+
+ {/each} +
+
+ +
+ {#each data.visitorByOS as visitorByOS} +
+
+ +
{visitorByOS.os ?? 'Undefined OS'}
+
+
{visitorByOS.count}
+
+ {/each} +
+
+
+
+
diff --git a/redirect/bun.lockb b/redirect/bun.lockb index f62cded..4654d19 100755 Binary files a/redirect/bun.lockb and b/redirect/bun.lockb differ diff --git a/redirect/package.json b/redirect/package.json index 2b0287d..52dfcd9 100644 --- a/redirect/package.json +++ b/redirect/package.json @@ -8,11 +8,13 @@ "dependencies": { "@elysiajs/cors": "^0.6.0", "@types/pg": "^8.10.2", + "@types/ua-parser-js": "^0.7.39", "elysia": "latest", "kysely": "^0.26.3", "magic-regexp": "^0.7.0", "nanoid": "^5.0.1", "pg": "^8.11.3", + "ua-parser-js": "^1.0.37", "zod": "^3.22.2" }, "devDependencies": { diff --git a/redirect/src/index.ts b/redirect/src/index.ts index 31cc042..c2c1d15 100644 --- a/redirect/src/index.ts +++ b/redirect/src/index.ts @@ -1,6 +1,7 @@ import { Elysia } from 'elysia' import { db } from './database' import { cors } from '@elysiajs/cors' +import { UAParser } from 'ua-parser-js' const fallback_url = Bun.env.FALLBACK_URL ?? 'https://shortener.tzgyn.com' @@ -18,6 +19,10 @@ app.get( await fetch(`https://api.ipbase.com/v2/info?ip=${ip}`) ).json() + const user_agent = request.headers.get('User-Agent') + + const ua_parser = new UAParser(user_agent ?? '') + try { const shortener = await db .selectFrom('shortener') @@ -32,6 +37,9 @@ app.get( country_code: geolocation.data.location.country .alpha2 as string, city: geolocation.data.location.city.name as string, + device_type: ua_parser.getDevice().type, + device_vendor: ua_parser.getDevice().vendor, + os: ua_parser.getOS().name, } await db.insertInto('visitor').values(visitor_data).execute() diff --git a/redirect/src/types.ts b/redirect/src/types.ts index ba2e849..31fc5f9 100644 --- a/redirect/src/types.ts +++ b/redirect/src/types.ts @@ -31,6 +31,9 @@ export interface VisitorTable { country: string country_code: string city: string + device_type: string | null + device_vendor: string | null + os: string | null created_at: ColumnType }