added device analytics

pull/3/head
TZGyn 2 years ago
parent f5f911c30e
commit 3076a6ad20
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

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

@ -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": {}
}
}

@ -36,6 +36,13 @@
"when": 1704723435338, "when": 1704723435338,
"tag": "0004_cynical_the_hand", "tag": "0004_cynical_the_hand",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1704837856450,
"tag": "0005_striped_prism",
"breakpoints": true
} }
] ]
} }

@ -77,6 +77,9 @@ export const visitor = pgTable('visitor', {
}).notNull(), }).notNull(),
country: varchar('country', { length: 255 }).notNull(), country: varchar('country', { length: 255 }).notNull(),
city: varchar('city', { 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 }) => ({ export const visitorRelations = relations(visitor, ({ one }) => ({

@ -154,7 +154,7 @@
</div> </div>
{#if data.shorteners.length > 0} {#if data.shorteners.length > 0}
<div class="flex flex-col gap-4 overflow-scroll p-4"> <div class="flex flex-wrap gap-4 overflow-scroll p-4">
{#each data.shorteners as shortener} {#each data.shorteners as shortener}
<Card.Root class="w-full max-w-[500px]"> <Card.Root class="w-full max-w-[500px]">
<Card.Header> <Card.Header>

@ -1,7 +1,7 @@
import { db } from '$lib/db' import { db } from '$lib/db'
import { redirect } from '@sveltejs/kit' import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types' 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' import { visitor as visitorSchema } from '$lib/db/schema'
export const load = (async (event) => { export const load = (async (event) => {
@ -23,13 +23,22 @@ export const load = (async (event) => {
throw redirect(303, '/') throw redirect(303, '/')
} }
const now = new Date()
const visitor = await db const visitor = await db
.select({ .select({
count: sql<number>`cast(count(*) as int)`, count: sql<number>`cast(count(*) as int)`,
month: sql<number>`cast(to_char(${visitorSchema.createdAt}, 'MM') as int)`, month: sql<number>`cast(to_char(${visitorSchema.createdAt}, 'MM') as int)`,
}) })
.from(visitorSchema) .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')`) .groupBy(sql`to_char(${visitorSchema.createdAt}, 'MM')`)
const visitorByCountry = await db const visitorByCountry = await db
@ -57,5 +66,40 @@ export const load = (async (event) => {
visitorSchema.city, visitorSchema.city,
) )
return { shortener, visitor, visitorByCountry, visitorByCity } const visitorByOS = await db
.select({
count: sql<number>`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<number>`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<number>`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 }) satisfies PageServerLoad

@ -6,6 +6,12 @@
import type { ApexOptions } from 'apexcharts' import type { ApexOptions } from 'apexcharts'
import { mode } from 'mode-watcher' import { mode } from 'mode-watcher'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import {
ShieldQuestion,
Smartphone,
Tablet,
TabletSmartphone,
} from 'lucide-svelte'
export let data: PageData export let data: PageData
@ -102,8 +108,8 @@
</div> </div>
<Separator /> <Separator />
<div class="flex flex-col gap-4 overflow-y-scroll p-4"> <div class="flex flex-wrap gap-4 overflow-y-scroll p-4">
<Card.Root class="max-w-[700px]"> <Card.Root class="w-[700px]">
<Card.Header> <Card.Header>
<Card.Title>Clicks</Card.Title> <Card.Title>Clicks</Card.Title>
<Card.Description <Card.Description
@ -113,7 +119,7 @@
<div bind:this={container}></div> <div bind:this={container}></div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root class="max-w-[500px]"> <Card.Root class="min-h-[500px] w-[500px]">
<Tabs.Root value="country"> <Tabs.Root value="country">
<Card.Header <Card.Header
class="flex w-full flex-row items-center justify-between space-y-0"> class="flex w-full flex-row items-center justify-between space-y-0">
@ -157,4 +163,73 @@
</Card.Content> </Card.Content>
</Tabs.Root> </Tabs.Root>
</Card.Root> </Card.Root>
<Card.Root class="min-h-[500px] w-[500px]">
<Tabs.Root value="vendor">
<Card.Header
class="flex w-full flex-row items-center justify-between space-y-0">
<div>
<Card.Title>Devices</Card.Title>
<Card.Description>Devices by Country/City</Card.Description>
</div>
<Tabs.List>
<Tabs.Trigger value="vendor">Vendor</Tabs.Trigger>
<Tabs.Trigger value="type">Type</Tabs.Trigger>
<Tabs.Trigger value="os">OS</Tabs.Trigger>
</Tabs.List>
</Card.Header>
<Card.Content>
<Tabs.Content value="vendor">
<div class="flex flex-col gap-6">
{#each data.visitorByDeviceVendor as visitorByDeviceVendor}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<TabletSmartphone />
<div>
{visitorByDeviceVendor.vendor ??
'Undefined Vendor'}
</div>
</div>
<div>{visitorByDeviceVendor.count}</div>
</div>
{/each}
</div>
</Tabs.Content>
<Tabs.Content value="type">
<div class="flex flex-col gap-6">
{#each data.visitorByDeviceType as visitorByDeviceType}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
{#if visitorByDeviceType.type === 'mobile'}
<Smartphone />
{:else if visitorByDeviceType.type === 'tablet'}
<Tablet />
{:else}
<TabletSmartphone />
{/if}
<div>
{visitorByDeviceType.type ??
'Undefined Device Type'}
</div>
</div>
<div>{visitorByDeviceType.count}</div>
</div>
{/each}
</div>
</Tabs.Content>
<Tabs.Content value="os">
<div class="flex flex-col gap-6">
{#each data.visitorByOS as visitorByOS}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<TabletSmartphone />
<div>{visitorByOS.os ?? 'Undefined OS'}</div>
</div>
<div>{visitorByOS.count}</div>
</div>
{/each}
</div>
</Tabs.Content>
</Card.Content>
</Tabs.Root>
</Card.Root>
</div> </div>

Binary file not shown.

@ -8,11 +8,13 @@
"dependencies": { "dependencies": {
"@elysiajs/cors": "^0.6.0", "@elysiajs/cors": "^0.6.0",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"@types/ua-parser-js": "^0.7.39",
"elysia": "latest", "elysia": "latest",
"kysely": "^0.26.3", "kysely": "^0.26.3",
"magic-regexp": "^0.7.0", "magic-regexp": "^0.7.0",
"nanoid": "^5.0.1", "nanoid": "^5.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"ua-parser-js": "^1.0.37",
"zod": "^3.22.2" "zod": "^3.22.2"
}, },
"devDependencies": { "devDependencies": {

@ -1,6 +1,7 @@
import { Elysia } from 'elysia' import { Elysia } from 'elysia'
import { db } from './database' import { db } from './database'
import { cors } from '@elysiajs/cors' import { cors } from '@elysiajs/cors'
import { UAParser } from 'ua-parser-js'
const fallback_url = Bun.env.FALLBACK_URL ?? 'https://shortener.tzgyn.com' 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}`) await fetch(`https://api.ipbase.com/v2/info?ip=${ip}`)
).json() ).json()
const user_agent = request.headers.get('User-Agent')
const ua_parser = new UAParser(user_agent ?? '')
try { try {
const shortener = await db const shortener = await db
.selectFrom('shortener') .selectFrom('shortener')
@ -32,6 +37,9 @@ app.get(
country_code: geolocation.data.location.country country_code: geolocation.data.location.country
.alpha2 as string, .alpha2 as string,
city: geolocation.data.location.city.name 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() await db.insertInto('visitor').values(visitor_data).execute()

@ -31,6 +31,9 @@ export interface VisitorTable {
country: string country: string
country_code: string country_code: string
city: string city: string
device_type: string | null
device_vendor: string | null
os: string | null
created_at: ColumnType<Date, string | undefined, never> created_at: ColumnType<Date, string | undefined, never>
} }

Loading…
Cancel
Save