feat: currency selection support (#72)

* initial UI for currency selection

* add task to purge invitation tokens

* group API contracts

* fix type import

* use auth middleware

* add currency setting support (UI)

* use group settings for format currency

* fix casing
This commit is contained in:
Hayden 2022-10-15 12:15:55 -08:00 committed by GitHub
parent 1cc38d6a5c
commit 461be2afca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 930 additions and 343 deletions

View file

@ -27,6 +27,7 @@ tasks:
- "./scripts/process-types.py"
generates:
- "./frontend/lib/api/types/data-contracts.ts"
- "./backend/ent/schema"
- "./backend/app/api/docs/swagger.json"
- "./backend/app/api/docs/swagger.yaml"

View file

@ -21,6 +21,63 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": {
"post": {
"security": [
@ -32,7 +89,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"User"
"Group"
],
"summary": "Get the current user",
"parameters": [
@ -1116,6 +1173,37 @@ const docTemplate = `{
}
}
},
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": {
"type": "object",
"properties": {

View file

@ -13,6 +13,63 @@
},
"basePath": "/api",
"paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": {
"post": {
"security": [
@ -24,7 +81,7 @@
"application/json"
],
"tags": [
"User"
"Group"
],
"summary": "Get the current user",
"parameters": [
@ -1108,6 +1165,37 @@
}
}
},
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": {
"type": "object",
"properties": {

View file

@ -9,6 +9,26 @@ definitions:
title:
type: string
type: object
repo.Group:
properties:
createdAt:
type: string
currency:
type: string
id:
type: string
name:
type: string
updatedAt:
type: string
type: object
repo.GroupUpdate:
properties:
currency:
type: string
name:
type: string
type: object
repo.ItemAttachment:
properties:
createdAt:
@ -415,6 +435,40 @@ info:
title: Go API Templates
version: "1.0"
paths:
/v1/groups:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get the current user's group
tags:
- Group
put:
parameters:
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.GroupUpdate'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Updates some fields of the current users group
tags:
- Group
/v1/groups/invitations:
post:
parameters:
@ -435,7 +489,7 @@ paths:
- Bearer: []
summary: Get the current user
tags:
- User
- Group
/v1/items:
get:
parameters:

View file

@ -110,7 +110,7 @@ func run(cfg *config.Config) error {
app.db = c
app.repos = repo.New(c, cfg.Storage.Data)
app.services = services.NewServices(app.repos)
app.services = services.New(app.repos)
// =========================================================================
// Start Server
@ -138,6 +138,14 @@ func run(cfg *config.Config) error {
Msg("failed to purge expired tokens")
}
})
go app.startBgTask(time.Duration(24)*time.Hour, func() {
_, err := app.repos.Groups.InvitationPurge(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to purge expired invitations")
}
})
// TODO: Remove through external API that does setup
if cfg.Demo {

View file

@ -72,6 +72,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate())
// TODO: I don't like /groups being the URL for users
r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet())
r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate())
r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll())
r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())

View file

@ -2,8 +2,10 @@ package v1
import (
"net/http"
"strings"
"time"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log"
@ -22,9 +24,63 @@ type (
}
)
// HandleUserSelf godoc
// HandleGroupGet godoc
// @Summary Get the current user's group
// @Tags Group
// @Produce json
// @Success 200 {object} repo.Group
// @Router /v1/groups [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.Get(ctx)
if err != nil {
log.Err(err).Msg("failed to get group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ
server.Respond(w, http.StatusOK, group)
}
}
// HandleGroupUpdate godoc
// @Summary Updates some fields of the current users group
// @Tags Group
// @Produce json
// @Param payload body repo.GroupUpdate true "User Data"
// @Success 200 {object} repo.Group
// @Router /v1/groups [Put]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := repo.GroupUpdate{}
if err := server.Decode(r, &data); err != nil {
server.RespondError(w, http.StatusBadRequest, err)
return
}
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
if err != nil {
log.Err(err).Msg("failed to update group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
server.Respond(w, http.StatusOK, group)
}
}
// HandleGroupInvitationsCreate godoc
// @Summary Get the current user
// @Tags User
// @Tags Group
// @Produce json
// @Param payload body GroupInvitationCreate true "User Data"
// @Success 200 {object} GroupInvitation
@ -36,7 +92,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
if err := server.Decode(r, &data); err != nil {
log.Err(err).Msg("failed to decode user registration data")
server.RespondError(w, http.StatusInternalServerError, err)
server.RespondError(w, http.StatusBadRequest, err)
return
}
@ -46,7 +102,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
ctx := services.NewContext(r.Context())
token, err := ctrl.svc.User.NewInvitation(ctx, data.Uses, data.ExpiresAt)
token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt)
if err != nil {
log.Err(err).Msg("failed to create new token")
server.RespondError(w, http.StatusInternalServerError, err)

View file

@ -121,6 +121,9 @@ const DefaultCurrency = CurrencyUsd
// Currency values.
const (
CurrencyUsd Currency = "usd"
CurrencyEur Currency = "eur"
CurrencyGbp Currency = "gbp"
CurrencyJpy Currency = "jpy"
)
func (c Currency) String() string {
@ -130,7 +133,7 @@ func (c Currency) String() string {
// CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save.
func CurrencyValidator(c Currency) error {
switch c {
case CurrencyUsd:
case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy:
return nil
default:
return fmt.Errorf("group: invalid enum value for currency field: %q", c)

View file

@ -127,7 +127,7 @@ var (
{Name: "created_at", Type: field.TypeTime},
{Name: "updated_at", Type: field.TypeTime},
{Name: "name", Type: field.TypeString, Size: 255},
{Name: "currency", Type: field.TypeEnum, Enums: []string{"usd"}, Default: "usd"},
{Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy"}, Default: "usd"},
}
// GroupsTable holds the schema information for the "groups" table.
GroupsTable = &schema.Table{

View file

@ -27,7 +27,7 @@ func (Group) Fields() []ent.Field {
NotEmpty(),
field.Enum("currency").
Default("usd").
Values("usd"), // TODO: add more currencies
Values("usd", "eur", "gbp", "jpy"), // TODO: add more currencies
}
}

View file

@ -6,6 +6,7 @@ import (
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group"
"github.com/hay-kot/homebox/backend/ent/groupinvitationtoken"
)
@ -15,11 +16,16 @@ type GroupRepository struct {
type (
Group struct {
ID uuid.UUID
Name string
CreatedAt time.Time
UpdatedAt time.Time
Currency string
ID uuid.UUID `json:"id,omitempty"`
Name string `json:"name,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
Currency string `json:"currency,omitempty"`
}
GroupUpdate struct {
Name string `json:"name"`
Currency string `json:"currency"`
}
GroupInvitationCreate struct {
@ -69,6 +75,17 @@ func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group,
Save(ctx))
}
func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data GroupUpdate) (Group, error) {
currency := group.Currency(data.Currency)
entity, err := r.db.Group.UpdateOneID(ID).
SetName(data.Name).
SetCurrency(currency).
Save(ctx)
return mapToGroupErr(entity, err)
}
func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, error) {
return mapToGroupErr(r.db.Group.Get(ctx, id))
}

View file

@ -18,3 +18,16 @@ func Test_Group_Create(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, g.ID, foundGroup.ID)
}
func Test_Group_Update(t *testing.T) {
g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
assert.NoError(t, err)
g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{
Name: "test2",
Currency: "eur",
})
assert.NoError(t, err)
assert.Equal(t, "test2", g.Name)
assert.Equal(t, "eur", g.Currency)
}

View file

@ -4,18 +4,20 @@ import "github.com/hay-kot/homebox/backend/internal/repo"
type AllServices struct {
User *UserService
Group *GroupService
Location *LocationService
Labels *LabelService
Items *ItemService
}
func NewServices(repos *repo.AllRepos) *AllServices {
func New(repos *repo.AllRepos) *AllServices {
if repos == nil {
panic("repos cannot be nil")
}
return &AllServices{
User: &UserService{repos},
Group: &GroupService{repos},
Location: &LocationService{repos},
Labels: &LabelService{repos},
Items: &ItemService{

View file

@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
tClient = client
tRepos = repo.New(tClient, os.TempDir()+"/homebox")
tSvc = NewServices(tRepos)
tSvc = New(tRepos)
defer client.Close()
bootstrap()

View file

@ -0,0 +1,47 @@
package services
import (
"errors"
"strings"
"time"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/pkgs/hasher"
)
type GroupService struct {
repos *repo.AllRepos
}
func (svc *GroupService) Get(ctx Context) (repo.Group, error) {
return svc.repos.Groups.GroupByID(ctx.Context, ctx.GID)
}
func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) {
if data.Name == "" {
data.Name = ctx.User.GroupName
}
if data.Currency == "" {
return repo.Group{}, errors.New("currency cannot be empty")
}
data.Currency = strings.ToLower(data.Currency)
return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data)
}
func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
token := hasher.GenerateToken()
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
Token: token.Hash,
Uses: uses,
ExpiresAt: expiresAt,
})
if err != nil {
return "", err
}
return token.Raw, nil
}

View file

@ -186,21 +186,6 @@ func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error {
return svc.repos.Users.Delete(ctx, ID)
}
func (svc *UserService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
token := hasher.GenerateToken()
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
Token: token.Hash,
Uses: uses,
ExpiresAt: expiresAt,
})
if err != nil {
return "", err
}
return token.Raw, nil
}
func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) {
usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID)
if err != nil {

View file

@ -25,7 +25,7 @@
},
modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: [Object, String, Boolean] as any,
type: [Object, String] as any,
default: null,
},
items: {
@ -53,10 +53,19 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function compare(a: any, b: any): boolean {
if (props.value != null) {
if (props.value) {
return a[props.value] === b[props.value];
}
return a === b;
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return JSON.stringify(a) === JSON.stringify(b);
}
watch(

View file

@ -0,0 +1,22 @@
<template>
{{ value }}
</template>
<script setup lang="ts">
const props = defineProps({
amount: {
type: String,
required: true,
},
});
const fmt = await useFormatCurrency();
const value = computed(() => {
if (!props.amount || props.amount === "0") {
return "";
}
return fmt(props.amount);
});
</script>

View file

@ -7,9 +7,8 @@
</dt>
<dd class="mt-1 text-sm text-base-content sm:col-span-2 sm:mt-0">
<slot :name="detail.slot || detail.name" v-bind="{ detail }">
<template v-if="detail.type == 'date'">
<DateTime :date="detail.text" />
</template>
<DateTime v-if="detail.type == 'date'" :date="detail.text" />
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
<template v-else>
{{ detail.text }}
</template>
@ -21,11 +20,11 @@
</template>
<script setup lang="ts">
import type { DateDetail, Detail } from "./types";
import type { CustomDetail, Detail } from "./types";
defineProps({
details: {
type: Object as () => (Detail | DateDetail)[],
type: Object as () => (Detail | CustomDetail)[],
required: true,
},
});

View file

@ -1,15 +1,25 @@
export type StringLike = string | number | boolean;
export type DateDetail = {
type BaseDetail = {
name: string;
text: string | Date;
slot?: string;
type: "date";
};
export type Detail = {
name: string;
type DateDetail = BaseDetail & {
type: "date";
text: Date | string;
};
type CurrencyDetail = BaseDetail & {
type: "currency";
text: string;
};
export type CustomDetail = DateDetail | CurrencyDetail;
export type Detail = BaseDetail & {
text: StringLike;
slot?: string;
type?: "text";
};
export type Details = Array<Detail | CustomDetail>;

View file

@ -0,0 +1,21 @@
const cache = {
currency: "",
};
export function ResetCurrency() {
cache.currency = "";
}
export async function useFormatCurrency() {
if (!cache.currency) {
const client = useUserApi();
const { data: group } = await client.group.get();
if (group) {
cache.currency = group.currency;
}
}
return (value: number | string) => fmtCurrency(value, cache.currency);
}

View file

@ -1,35 +1,5 @@
import { Ref } from "vue";
export type DaisyTheme =
| "light"
| "dark"
| "cupcake"
| "bumblebee"
| "emerald"
| "corporate"
| "synthwave"
| "retro"
| "cyberpunk"
| "valentine"
| "halloween"
| "garden"
| "forest"
| "aqua"
| "lofi"
| "pastel"
| "fantasy"
| "wireframe"
| "black"
| "luxury"
| "dracula"
| "cmyk"
| "autumn"
| "business"
| "acid"
| "lemonade"
| "night"
| "coffee"
| "winter";
import { DaisyTheme } from "~~/lib/data/themes";
export type LocationViewPreferences = {
showDetails: boolean;

View file

@ -1,5 +1,5 @@
import { ComputedRef } from "vue";
import { DaisyTheme } from "./use-preferences";
import { DaisyTheme } from "~~/lib/data/themes";
export interface UseTheme {
theme: ComputedRef<DaisyTheme>;

View file

@ -1,10 +1,70 @@
<script setup lang="ts"></script>
<template>
<div>
<AppToast />
<AppHeader />
<main class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen">
<main>
<slot></slot>
</main>
</div>
</template>
<script lang="ts" setup>
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
/**
* Store Provider Initialization
*/
const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm;
const rmLabelStoreObserver = defineObserver("labelStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLabel)) {
labelStore.refresh();
}
console.debug("labelStore handler called by observer");
},
});
const locationStore = useLocationStore();
const reLocation = /\/api\/v1\/locations\/.*/gm;
const rmLocationStoreObserver = defineObserver("locationStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLocation)) {
locationStore.refresh();
}
console.debug("locationStore handler called by observer");
},
});
const itemStore = useItemStore();
const reItem = /\/api\/v1\/items\/.*/gm;
const rmItemStoreObserver = defineObserver("itemStore", {
handler: r => {
if (r.status === 201 || r.url.match(reItem)) {
itemStore.refresh();
}
console.debug("itemStore handler called by observer");
},
});
const eventBus = useEventBus();
eventBus.on(
EventTypes.ClearStores,
() => {
labelStore.refresh();
itemStore.refresh();
locationStore.refresh();
},
"stores"
);
onUnmounted(() => {
rmLabelStoreObserver();
rmLocationStoreObserver();
rmItemStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores");
});
</script>

View file

@ -1,69 +0,0 @@
<template>
<div>
<AppToast />
<AppHeader />
<main>
<slot></slot>
</main>
</div>
</template>
<script lang="ts" setup>
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
/**
* Store Provider Initialization
*/
const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm;
const rmLabelStoreObserver = defineObserver("labelStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLabel)) {
labelStore.refresh();
}
console.debug("labelStore handler called by observer");
},
});
const locationStore = useLocationStore();
const reLocation = /\/api\/v1\/locations\/.*/gm;
const rmLocationStoreObserver = defineObserver("locationStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLocation)) {
locationStore.refresh();
}
console.debug("locationStore handler called by observer");
},
});
const itemStore = useItemStore();
const reItem = /\/api\/v1\/items\/.*/gm;
const rmItemStoreObserver = defineObserver("itemStore", {
handler: r => {
if (r.status === 201 || r.url.match(reItem)) {
itemStore.refresh();
}
console.debug("itemStore handler called by observer");
},
});
const eventBus = useEventBus();
eventBus.on(
EventTypes.ClearStores,
() => {
labelStore.refresh();
itemStore.refresh();
locationStore.refresh();
},
"stores"
);
onUnmounted(() => {
rmLabelStoreObserver();
rmLocationStoreObserver();
rmItemStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores");
});
</script>

View file

@ -1,6 +1,5 @@
import { describe, test, expect } from "vitest";
import { factories } from "./factories";
import { sharedUserClient } from "./test-utils";
describe("[GET] /api/v1/status", () => {
test("server should respond", async () => {
@ -32,43 +31,4 @@ describe("first time user workflow (register, login, join group)", () => {
expect(response.status).toBe(204);
}
});
test("user should be able to join create join token and have user signup", async () => {
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
// Create User 2 with token
const duplicateUser = factories.user();
duplicateUser.token = data.token;
const { response: registerResp } = await api.register(duplicateUser);
expect(registerResp.status).toBe(204);
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
expect(loginResp.status).toBe(200);
// Get Self and Assert
const client2 = factories.client.user(loginData.token);
const { data: user2 } = await client2.user.self();
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();
expect(deleteResp.status).toBe(204);
});
});

View file

@ -0,0 +1,66 @@
import { faker } from "@faker-js/faker";
import { describe, test, expect } from "vitest";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
describe("first time user workflow (register, login, join group)", () => {
test("user should be able to update group", async () => {
const { client } = await factories.client.singleUse();
const name = faker.name.firstName();
const { response, data: group } = await client.group.update({
name,
currency: "eur",
});
expect(response.status).toBe(200);
expect(group.name).toBe(name);
});
test("user should be able to get own group", async () => {
const { client } = await factories.client.singleUse();
const { response, data: group } = await client.group.get();
expect(response.status).toBe(200);
expect(group.name).toBeTruthy();
expect(group.currency).toBe("USD");
});
test("user should be able to join create join token and have user signup", async () => {
const api = factories.client.public();
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
// Create User 2 with token
const duplicateUser = factories.user();
duplicateUser.token = data.token;
const { response: registerResp } = await api.register(duplicateUser);
expect(registerResp.status).toBe(204);
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
expect(loginResp.status).toBe(200);
// Get Self and Assert
const client2 = factories.client.user(loginData.token);
const { data: user2 } = await client2.user.self();
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();
expect(deleteResp.status).toBe(204);
});
});

View file

@ -1,5 +1,5 @@
import { BaseAPI, route } from "../base";
import { GroupInvitation, GroupInvitationCreate } from "../types/data-contracts";
import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
export class GroupApi extends BaseAPI {
createInvitation(data: GroupInvitationCreate) {
@ -8,4 +8,17 @@ export class GroupApi extends BaseAPI {
body: data,
});
}
update(data: GroupUpdate) {
return this.http.put<GroupUpdate, Group>({
url: route("/groups"),
body: data,
});
}
get() {
return this.http.get<Group>({
url: route("/groups"),
});
}
}

View file

@ -16,6 +16,19 @@ export interface DocumentOut {
title: string;
}
export interface Group {
createdAt: Date;
currency: string;
id: string;
name: string;
updatedAt: Date;
}
export interface GroupUpdate {
currency: string;
name: string;
}
export interface ItemAttachment {
createdAt: Date;
document: DocumentOut;

View file

@ -0,0 +1,35 @@
export type Codes = "USD" | "EUR" | "GBP" | "JPY";
export type Currency = {
code: Codes;
local: string;
symbol: string;
name: string;
};
export const currencies: Currency[] = [
{
code: "USD",
local: "en-US",
symbol: "$",
name: "US Dollar",
},
{
code: "EUR",
local: "de-DE",
symbol: "€",
name: "Euro",
},
{
code: "GBP",
local: "en-GB",
symbol: "£",
name: "British Pound",
},
{
code: "JPY",
local: "ja-JP",
symbol: "¥",
name: "Japanese Yen",
},
];

150
frontend/lib/data/themes.ts Normal file
View file

@ -0,0 +1,150 @@
export type DaisyTheme =
| "light"
| "dark"
| "cupcake"
| "bumblebee"
| "emerald"
| "corporate"
| "synthwave"
| "retro"
| "cyberpunk"
| "valentine"
| "halloween"
| "garden"
| "forest"
| "aqua"
| "lofi"
| "pastel"
| "fantasy"
| "wireframe"
| "black"
| "luxury"
| "dracula"
| "cmyk"
| "autumn"
| "business"
| "acid"
| "lemonade"
| "night"
| "coffee"
| "winter";
export type ThemeOption = {
label: string;
value: DaisyTheme;
};
export const themes: ThemeOption[] = [
{
label: "Garden",
value: "garden",
},
{
label: "Light",
value: "light",
},
{
label: "Cupcake",
value: "cupcake",
},
{
label: "Bumblebee",
value: "bumblebee",
},
{
label: "Emerald",
value: "emerald",
},
{
label: "Corporate",
value: "corporate",
},
{
label: "Synthwave",
value: "synthwave",
},
{
label: "Retro",
value: "retro",
},
{
label: "Cyberpunk",
value: "cyberpunk",
},
{
label: "Valentine",
value: "valentine",
},
{
label: "Halloween",
value: "halloween",
},
{
label: "Forest",
value: "forest",
},
{
label: "Aqua",
value: "aqua",
},
{
label: "Lofi",
value: "lofi",
},
{
label: "Pastel",
value: "pastel",
},
{
label: "Fantasy",
value: "fantasy",
},
{
label: "Wireframe",
value: "wireframe",
},
{
label: "Black",
value: "black",
},
{
label: "Luxury",
value: "luxury",
},
{
label: "Dracula",
value: "dracula",
},
{
label: "Cmyk",
value: "cmyk",
},
{
label: "Autumn",
value: "autumn",
},
{
label: "Business",
value: "business",
},
{
label: "Acid",
value: "acid",
},
{
label: "Lemonade",
value: "lemonade",
},
{
label: "Night",
value: "night",
},
{
label: "Coffee",
value: "coffee",
},
{
label: "Winter",
value: "winter",
},
];

View file

@ -0,0 +1,15 @@
import { useAuthStore } from "~~/stores/auth";
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore();
const api = useUserApi();
if (!auth.self) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
}
});

View file

@ -5,8 +5,9 @@
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});
@ -15,15 +16,6 @@
const auth = useAuthStore();
if (auth.self === null) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
}
const itemsStore = useItemStore();
const items = computed(() => itemsStore.items);

View file

@ -6,7 +6,7 @@
import { capitalize } from "~~/lib/strings";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
import { Detail, Details } from "~~/components/global/DetailsSection/types";
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();
@ -145,7 +145,7 @@
});
const warrantyDetails = computed(() => {
const details: (Detail | DateDetail)[] = [
const details: Details = [
{
name: "Lifetime Warranty",
text: item.value?.lifetimeWarranty ? "Yes" : "No",
@ -180,7 +180,7 @@
return item.value?.purchaseFrom || item.value?.purchasePrice !== "0";
});
const purchaseDetails = computed<Array<Detail | DateDetail>>(() => {
const purchaseDetails = computed<Details>(() => {
return [
{
name: "Purchased From",
@ -188,7 +188,8 @@
},
{
name: "Purchase Price",
text: item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
text: item.value?.purchasePrice || "",
type: "currency",
},
{
name: "Purchase Date",
@ -205,7 +206,7 @@
return item.value?.soldTo || item.value?.soldPrice !== "0";
});
const soldDetails = computed<Array<Detail | DateDetail>>(() => {
const soldDetails = computed<Details>(() => {
return [
{
name: "Sold To",
@ -213,7 +214,8 @@
},
{
name: "Sold Price",
text: item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
text: item.value?.soldPrice || "",
type: "currency",
},
{
name: "Sold At",

View file

@ -1,6 +1,6 @@
<script setup>
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const show = reactive({

View file

@ -4,8 +4,9 @@
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { DateDetail, Detail } from "~~/components/global/DetailsSection/types";
import type { CustomDetail, Detail } from "~~/components/global/DetailsSection/types";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();
@ -23,7 +23,7 @@
return data;
});
const details = computed<(Detail | DateDetail)[]>(() => {
const details = computed<(Detail | CustomDetail)[]>(() => {
const details = [
{
name: "Name",

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import { Detail, DateDetail } from "~~/components/global/DetailsSection/types";
import { Detail, CustomDetail } from "~~/components/global/DetailsSection/types";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();
@ -23,7 +23,7 @@
return data;
});
const details = computed<(Detail | DateDetail)[]>(() => {
const details = computed<(Detail | CustomDetail)[]>(() => {
const details = [
{
name: "Name",

View file

@ -1,15 +1,70 @@
<script setup lang="ts">
import { Detail } from "~~/components/global/DetailsSection/types";
import { DaisyTheme } from "~~/composables/use-preferences";
import { useAuthStore } from "~~/stores/auth";
import { themes } from "~~/lib/data/themes";
import { currencies, Currency } from "~~/lib/data/currency";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
useHead({
title: "Homebox | Profile",
});
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
// Currency Selection
const currency = ref<Currency>(currencies[0]);
watch(currency, () => {
if (group.value) {
group.value.currency = currency.value.code;
}
console.log(group.value);
});
const currencyExample = computed(() => {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.value ? currency.value.code : "USD",
});
return formatter.format(1000);
});
const { data: group } = useAsyncData(async () => {
const { data } = await api.group.get();
return data;
});
// Sync Initial Currency
watch(group, () => {
if (group.value) {
const found = currencies.find(c => c.code === group.value.currency);
if (found) {
currency.value = found;
}
}
});
async function updateGroup() {
const { data, error } = await api.group.update({
name: group.value.name,
currency: group.value.currency,
});
if (error) {
notify.error("Failed to update group");
return;
}
group.value = data;
notify.success("Group updated");
}
const pubApi = usePublicApi();
const { data: status } = useAsyncData(async () => {
const { data } = await pubApi.status();
@ -19,126 +74,6 @@
const { setTheme } = useTheme();
type ThemeOption = {
label: string;
value: DaisyTheme;
};
const themes: ThemeOption[] = [
{
label: "Garden",
value: "garden",
},
{
label: "Light",
value: "light",
},
{
label: "Cupcake",
value: "cupcake",
},
{
label: "Bumblebee",
value: "bumblebee",
},
{
label: "Emerald",
value: "emerald",
},
{
label: "Corporate",
value: "corporate",
},
{
label: "Synthwave",
value: "synthwave",
},
{
label: "Retro",
value: "retro",
},
{
label: "Cyberpunk",
value: "cyberpunk",
},
{
label: "Valentine",
value: "valentine",
},
{
label: "Halloween",
value: "halloween",
},
{
label: "Forest",
value: "forest",
},
{
label: "Aqua",
value: "aqua",
},
{
label: "Lofi",
value: "lofi",
},
{
label: "Pastel",
value: "pastel",
},
{
label: "Fantasy",
value: "fantasy",
},
{
label: "Wireframe",
value: "wireframe",
},
{
label: "Black",
value: "black",
},
{
label: "Luxury",
value: "luxury",
},
{
label: "Dracula",
value: "dracula",
},
{
label: "Cmyk",
value: "cmyk",
},
{
label: "Autumn",
value: "autumn",
},
{
label: "Business",
value: "business",
},
{
label: "Acid",
value: "acid",
},
{
label: "Lemonade",
value: "lemonade",
},
{
label: "Night",
value: "night",
},
{
label: "Coffee",
value: "coffee",
},
{
label: "Winter",
value: "winter",
},
];
const auth = useAuthStore();
const details = computed(() => {
@ -154,10 +89,6 @@
] as Detail[];
});
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
async function deleteProfile() {
const result = await confirm.open(
"Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone."
@ -283,6 +214,27 @@
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader class="pb-0">
<Icon name="mdi-accounts" class="mr-2 -mt-1 text-base-600" />
<span class="text-base-600"> Group Settings </span>
<template #description>
Shared Group Settings. You may need to refresh your browser for some settings to apply.
</template>
</BaseSectionHeader>
</template>
<div v-if="group" class="p-5 pt-0">
<FormSelect v-model="currency" value="code" label="Currency Format" :items="currencies" />
<p class="m-2 text-sm">Example: {{ currencyExample }}</p>
<div class="mt-4 flex justify-end">
<BaseButton @click="updateGroup"> Update Group </BaseButton>
</div>
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>