feat: group statistics endpoint (#123)

* group statistics endpoint

* remove item store

* return possible errors

* add statistics tests
This commit is contained in:
Hayden 2022-11-01 13:58:05 -08:00 committed by GitHub
parent a886fa86ca
commit 7e0f1fac23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 201 additions and 74 deletions

View file

@ -24,6 +24,26 @@ type (
} }
) )
// HandleGroupGet godoc
// @Summary Get the current user's group
// @Tags Group
// @Produce json
// @Success 200 {object} repo.GroupStatistics
// @Router /v1/groups/statistics [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
stats, err := ctrl.repo.Groups.GroupStatistics(ctx, ctx.GID)
if err != nil {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, stats)
}
}
// HandleGroupGet godoc // HandleGroupGet godoc
// @Summary Get the current user's group // @Summary Get the current user's group
// @Tags Group // @Tags Group

View file

@ -76,6 +76,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
a.server.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword(), a.mwAuthToken) a.server.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword(), a.mwAuthToken)
a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), a.mwAuthToken) a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), a.mwAuthToken)
a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), a.mwAuthToken)
// TODO: I don't like /groups being the URL for users // TODO: I don't like /groups being the URL for users
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken) a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken)

View file

@ -113,6 +113,30 @@ const docTemplate = `{
} }
} }
}, },
"/v1/groups/statistics": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.GroupStatistics"
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -1178,6 +1202,23 @@ const docTemplate = `{
} }
} }
}, },
"repo.GroupStatistics": {
"type": "object",
"properties": {
"totalItems": {
"type": "integer"
},
"totalLabels": {
"type": "integer"
},
"totalLocations": {
"type": "integer"
},
"totalUsers": {
"type": "integer"
}
}
},
"repo.GroupUpdate": { "repo.GroupUpdate": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -105,6 +105,30 @@
} }
} }
}, },
"/v1/groups/statistics": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.GroupStatistics"
}
}
}
}
},
"/v1/items": { "/v1/items": {
"get": { "get": {
"security": [ "security": [
@ -1170,6 +1194,23 @@
} }
} }
}, },
"repo.GroupStatistics": {
"type": "object",
"properties": {
"totalItems": {
"type": "integer"
},
"totalLabels": {
"type": "integer"
},
"totalLocations": {
"type": "integer"
},
"totalUsers": {
"type": "integer"
}
}
},
"repo.GroupUpdate": { "repo.GroupUpdate": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -22,6 +22,17 @@ definitions:
updatedAt: updatedAt:
type: string type: string
type: object type: object
repo.GroupStatistics:
properties:
totalItems:
type: integer
totalLabels:
type: integer
totalLocations:
type: integer
totalUsers:
type: integer
type: object
repo.GroupUpdate: repo.GroupUpdate:
properties: properties:
currency: currency:
@ -560,6 +571,20 @@ paths:
summary: Get the current user summary: Get the current user
tags: tags:
- Group - Group
/v1/groups/statistics:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.GroupStatistics'
security:
- Bearer: []
summary: Get the current user's group
tags:
- Group
/v1/items: /v1/items:
get: get:
parameters: parameters:

View file

@ -72,13 +72,11 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -90,8 +88,6 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

View file

@ -41,6 +41,12 @@ type (
Uses int `json:"uses"` Uses int `json:"uses"`
Group Group `json:"group"` Group Group `json:"group"`
} }
GroupStatistics struct {
TotalUsers int `json:"totalUsers"`
TotalItems int `json:"totalItems"`
TotalLocations int `json:"totalLocations"`
TotalLabels int `json:"totalLabels"`
}
) )
var ( var (
@ -70,6 +76,25 @@ func mapToGroupInvitation(g *ent.GroupInvitationToken) GroupInvitation {
} }
} }
func (r *GroupRepository) GroupStatistics(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
q := `
SELECT
(SELECT COUNT(*) FROM users WHERE group_users = ?) AS total_users,
(SELECT COUNT(*) FROM items WHERE group_items = ? AND items.archived = false) AS total_items,
(SELECT COUNT(*) FROM locations WHERE group_locations = ?) AS total_locations,
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels
`
var stats GroupStatistics
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID)
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels)
if err != nil {
return GroupStatistics{}, err
}
return stats, nil
}
func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) { func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) {
return mapToGroupErr(r.db.Group.Create(). return mapToGroupErr(r.db.Group.Create().
SetName(name). SetName(name).

View file

@ -31,3 +31,16 @@ func Test_Group_Update(t *testing.T) {
assert.Equal(t, "test2", g.Name) assert.Equal(t, "test2", g.Name)
assert.Equal(t, "EUR", g.Currency) assert.Equal(t, "EUR", g.Currency)
} }
func Test_Group_GroupStatistics(t *testing.T) {
useItems(t, 20)
useLabels(t, 20)
stats, err := tRepos.Groups.GroupStatistics(context.Background(), tGroup.ID)
assert.NoError(t, err)
assert.Equal(t, 20, stats.TotalItems)
assert.Equal(t, 20, stats.TotalLabels)
assert.Equal(t, 1, stats.TotalUsers)
assert.Equal(t, 1, stats.TotalLocations)
}

View file

@ -9,7 +9,6 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels"; import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
@ -39,23 +38,11 @@
}, },
}); });
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(); const eventBus = useEventBus();
eventBus.on( eventBus.on(
EventTypes.ClearStores, EventTypes.ClearStores,
() => { () => {
labelStore.refresh(); labelStore.refresh();
itemStore.refresh();
locationStore.refresh(); locationStore.refresh();
}, },
"stores" "stores"
@ -64,7 +51,6 @@
onUnmounted(() => { onUnmounted(() => {
rmLabelStoreObserver(); rmLabelStoreObserver();
rmLocationStoreObserver(); rmLocationStoreObserver();
rmItemStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores"); eventBus.off(EventTypes.ClearStores, "stores");
}); });
</script> </script>

View file

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

View file

@ -24,6 +24,13 @@ export interface Group {
updatedAt: Date; updatedAt: Date;
} }
export interface GroupStatistics {
totalItems: number;
totalLabels: number;
totalLocations: number;
totalUsers: number;
}
export interface GroupUpdate { export interface GroupUpdate {
currency: string; currency: string;
name: string; name: string;

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from "~~/stores/auth"; import { useAuthStore } from "~~/stores/auth";
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels"; import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
@ -16,33 +15,33 @@
const auth = useAuthStore(); const auth = useAuthStore();
const itemsStore = useItemStore();
const items = computed(() => itemsStore.items);
const locationStore = useLocationStore(); const locationStore = useLocationStore();
const locations = computed(() => locationStore.locations); const locations = computed(() => locationStore.locations);
const labelsStore = useLabelStore(); const labelsStore = useLabelStore();
const labels = computed(() => labelsStore.labels); const labels = computed(() => labelsStore.labels);
const totalItems = computed(() => items.value?.length || 0); const { data: statistics } = useAsyncData(async () => {
const totalLocations = computed(() => locations.value?.length || 0); const { data } = await api.group.statistics();
const totalLabels = computed(() => labels.value?.length || 0); return data;
});
const stats = [ const stats = computed(() => {
{ return [
label: "Locations", {
value: totalLocations, label: "Locations",
}, value: statistics.value?.totalLocations || 0,
{ },
label: "Items", {
value: totalItems, label: "Items",
}, value: statistics.value?.totalItems || 0,
{ },
label: "Labels", {
value: totalLabels, label: "Labels",
}, value: statistics.value?.totalLabels || 0,
]; },
];
});
const importDialog = ref(false); const importDialog = ref(false);
const importCsv = ref(null); const importCsv = ref(null);
@ -141,7 +140,7 @@
class="grid grid-cols-1 divide-y divide-base-300 border-t border-base-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x" class="grid grid-cols-1 divide-y divide-base-300 border-t border-base-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
> >
<div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium"> <div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium">
<span class="text-base-900 font-bold">{{ stat.value.value }}</span> <span class="text-base-900 font-bold">{{ stat.value }}</span>
{{ " " }} {{ " " }}
<span class="text-base-600">{{ stat.label }}</span> <span class="text-base-600">{{ stat.label }}</span>
</div> </div>

View file

@ -1,33 +0,0 @@
import { defineStore } from "pinia";
import { ItemOut } from "~~/lib/api/types/data-contracts";
export const useItemStore = defineStore("items", {
state: () => ({
allItems: null as ItemOut[] | null,
client: useUserApi(),
}),
getters: {
/**
* items represents the items that are currently in the store. The store is
* synched with the server by intercepting the API calls and updating on the
* response.
*/
items(state): ItemOut[] {
if (state.allItems === null) {
Promise.resolve(this.refresh());
}
return state.allItems;
},
},
actions: {
async refresh(): Promise<ItemOut[]> {
const result = await this.client.items.getAll();
if (result.error) {
return result;
}
this.allItems = result.data.items;
return result;
},
},
});