From bc1c55e05757b48e6ea6f0d5fd06459f010fa0ec Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Tue, 12 Dec 2023 17:18:15 +0900 Subject: [PATCH 1/5] Send operation logs at opening a keyboard and flashing a keymap. --- firestore.rules | 10 +++++++++- src/actions/hid.action.ts | 12 ++++++++++-- src/services/provider/Firebase.ts | 31 +++++++++++++++++++++++++++++++ src/services/storage/Storage.ts | 7 +++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/firestore.rules b/firestore.rules index b967199f..9b864158 100644 --- a/firestore.rules +++ b/firestore.rules @@ -137,6 +137,14 @@ service cloud.firestore { && (request.auth.uid == resource.data.uid); } + match /logs/{definitionID}/operations/{operationLogID} { + allow create; + allow update: if false; + allow delete: if false; + allow read: if isAuthenticated() + && (request.auth.uid == getDefinition(definitionID).data.author_uid); + } + function isAuthenticated() { return request.auth.uid != null; } @@ -149,4 +157,4 @@ service cloud.firestore { return get(/databases/$(database)/documents/keyboards/v2/definitions/$(definitionID)); } } -} \ No newline at end of file +} diff --git a/src/actions/hid.action.ts b/src/actions/hid.action.ts index 44062093..934ee086 100644 --- a/src/actions/hid.action.ts +++ b/src/actions/hid.action.ts @@ -278,7 +278,7 @@ export const hidActionsThunk = { dispatch: ThunkDispatch, getState: () => RootState ) => { - const { app, entities } = getState(); + const { app, entities, storage } = getState(); const keyboard = entities.keyboard!; const result = await keyboard.open(); if (!result.success) { @@ -291,6 +291,10 @@ export const hidActionsThunk = { product_id: keyboard.getInformation().productId, product_name: keyboard.getInformation().productName, }); + await storage.instance!.sendOperationLog( + entities.keyboardDefinitionDocument!.id, + 'configure/open' + ); const isBleMicroPro = keyboard .getInformation() @@ -487,13 +491,17 @@ export const hidActionsThunk = { dispatch: ThunkDispatch, getState: () => RootState ) => { - const { app, entities } = getState(); + const { app, entities, storage } = getState(); const keyboard: IKeyboard = entities.keyboard!; sendEventToGoogleAnalytics('configure/flash', { vendor_id: keyboard.getInformation().vendorId, product_id: keyboard.getInformation().productId, product_name: keyboard.getInformation().productName, }); + await storage.instance!.sendOperationLog( + entities.keyboardDefinitionDocument!.id, + 'configure/flash' + ); const remaps = app.remaps; for (let layer = 0; layer < remaps.length; layer++) { const remap = remaps[layer]; diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 6add49ec..01c7790c 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -43,6 +43,7 @@ import { IFirmwareBuildingTask, BUILDABLE_FIRMWARE_QMK_FIRMWARE_VERSION, IBuildableFirmwareQmkFirmwareVersion, + IOperationLogType, } from '../storage/Storage'; import { IAuth, IAuthenticationResult } from '../auth/Auth'; import { IFirmwareCodePlace, IKeyboardFeatures } from '../../store/state'; @@ -1990,4 +1991,34 @@ export class FirebaseProvider implements IStorage, IAuth { ); } } + + async sendOperationLog( + keyboardDefinitionId: string, + operation: IOperationLogType + ): Promise { + try { + const doc: { + operation: IOperationLogType; + uid?: string; + createdAt: Date; + expireAt: Date; + } = { + operation, + createdAt: new Date(), + // This operation log will be deleted after 90 days. + expireAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + }; + if (this.auth.currentUser !== null) { + doc.uid = this.auth.currentUser.uid; + } + await this.db + .collection('logs') + .doc(keyboardDefinitionId) + .collection('operations') + .add(doc); + } catch (error) { + console.error(error); + // Ignore error. + } + } } diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index 977ae2c8..05d88906 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -316,6 +316,8 @@ export type IFirmwareBuildingTask = { updatedAt: Date; }; +export type IOperationLogType = 'configure/flash' | 'configure/open'; + /* eslint-disable no-unused-vars */ export interface IStorage { fetchKeyboardDefinitionDocumentByDeviceInfo( @@ -531,5 +533,10 @@ export interface IStorage { description: string ): Promise; fetchAllBuildableFirmwares(): Promise>; + + sendOperationLog( + keyboardDefinitionId: string, + operation: IOperationLogType + ): Promise; } /* eslint-enable no-unused-vars */ From ee0ff0a56d958fea5316e7ae8e070149591d924c Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Fri, 15 Dec 2023 14:28:42 +0900 Subject: [PATCH 2/5] Rename the `getCurrentAuthenticatedUser` with the `getCurrentAuthenticatedUserOrThrow`. --- src/actions/storage.action.ts | 4 ++-- src/components/common/auth/ProfileIcon.tsx | 6 +++--- src/components/configure/info/InfoDialog.tsx | 2 +- .../keymaplist/KeymapListPopover.tsx | 6 ++++-- src/components/keyboards/header/Header.tsx | 2 +- .../organizations/header/Header.tsx | 2 +- src/services/auth/Auth.ts | 3 ++- src/services/provider/Firebase.test.ts | 10 +++++----- src/services/provider/Firebase.ts | 19 +++++++++++++------ 9 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/actions/storage.action.ts b/src/actions/storage.action.ts index 613f2e8f..ec18b7bb 100644 --- a/src/actions/storage.action.ts +++ b/src/actions/storage.action.ts @@ -601,7 +601,7 @@ export const storageActionsThunk = { ) => { const { storage, auth, keyboards, github } = getState(); const keyboardDefinition = keyboards.createdefinition.keyboardDefinition!; - const user = auth.instance!.getCurrentAuthenticatedUser(); + const user = auth.instance!.getCurrentAuthenticatedUserOrThrow(); const githubProviderDataResult = getGitHubProviderData(user); if (!githubProviderDataResult.exists) { console.error('The user does not have a GitHub Provider data.'); @@ -673,7 +673,7 @@ export const storageActionsThunk = { const { storage, auth, keyboards, github } = getState(); const keyboardDefinition = keyboards.createdefinition.keyboardDefinition!; - const user = auth.instance!.getCurrentAuthenticatedUser(); + const user = auth.instance!.getCurrentAuthenticatedUserOrThrow(); const githubProviderDataResutl = getGitHubProviderData(user); if (!githubProviderDataResutl.exists) { console.error('The user does not have a GitHub Provider data.'); diff --git a/src/components/common/auth/ProfileIcon.tsx b/src/components/common/auth/ProfileIcon.tsx index 4ccb26e8..246341bd 100644 --- a/src/components/common/auth/ProfileIcon.tsx +++ b/src/components/common/auth/ProfileIcon.tsx @@ -73,7 +73,7 @@ export default class ProfileIcon extends React.Component< } renderLinkGoogleAccountMenu() { - const user = this.props.auth!.getCurrentAuthenticatedUser(); + const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); if (user && !getGoogleProviderData(user).exists) { return ( { this.onCloseKeymapSaveDialog(); diff --git a/src/components/keyboards/header/Header.tsx b/src/components/keyboards/header/Header.tsx index 9baab81a..1e0f7154 100644 --- a/src/components/keyboards/header/Header.tsx +++ b/src/components/keyboards/header/Header.tsx @@ -41,7 +41,7 @@ class Header extends React.Component { } renderAvatarIcon() { - const user = this.props.auth!.getCurrentAuthenticatedUser(); + const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); if (user) { const { menuAnchorEl } = this.state; diff --git a/src/components/organizations/header/Header.tsx b/src/components/organizations/header/Header.tsx index 9e97e6c2..fdfc219b 100644 --- a/src/components/organizations/header/Header.tsx +++ b/src/components/organizations/header/Header.tsx @@ -41,7 +41,7 @@ class Header extends React.Component { } renderAvatarIcon() { - const user = this.props.auth!.getCurrentAuthenticatedUser(); + const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); if (user) { const { menuAnchorEl } = this.state; diff --git a/src/services/auth/Auth.ts b/src/services/auth/Auth.ts index 8df2af62..a29c63c1 100644 --- a/src/services/auth/Auth.ts +++ b/src/services/auth/Auth.ts @@ -16,7 +16,8 @@ export interface IAuth { linkToGitHub(): Promise; // eslint-disable-next-line no-unused-vars subscribeAuthStatus(callback: (user: firebase.User | null) => void): void; - getCurrentAuthenticatedUser(): firebase.User; + getCurrentAuthenticatedUser(): firebase.User | null; + getCurrentAuthenticatedUserOrThrow(): firebase.User; getCurrentAuthenticatedUserDisplayName(): string; signOut(): Promise; } diff --git a/src/services/provider/Firebase.test.ts b/src/services/provider/Firebase.test.ts index 308d1bc1..f2013562 100644 --- a/src/services/provider/Firebase.test.ts +++ b/src/services/provider/Firebase.test.ts @@ -18,7 +18,7 @@ describe('FirebaseProvider', () => { describe('getCurrentAuthenticatedUserDisplayName', () => { test('user.displayName', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUser(): firebase.User { + getCurrentAuthenticatedUserOrThrow(): firebase.User { return { displayName: 'displayName1', } as firebase.User; @@ -33,7 +33,7 @@ describe('FirebaseProvider', () => { test('user.providerData[0].displayName', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUser(): firebase.User { + getCurrentAuthenticatedUserOrThrow(): firebase.User { return { displayName: null, providerData: [{ displayName: 'displayName2' }], @@ -49,7 +49,7 @@ describe('FirebaseProvider', () => { test('user.email', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUser(): firebase.User { + getCurrentAuthenticatedUserOrThrow(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], @@ -66,7 +66,7 @@ describe('FirebaseProvider', () => { test('user.email', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUser(): firebase.User { + getCurrentAuthenticatedUserOrThrow(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], @@ -83,7 +83,7 @@ describe('FirebaseProvider', () => { test('noname', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUser(): firebase.User { + getCurrentAuthenticatedUserOrThrow(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 01c7790c..d54e1126 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -619,12 +619,19 @@ export class FirebaseProvider implements IStorage, IAuth { ); } - getCurrentAuthenticatedUser(): firebase.User { - return this.auth.currentUser!; + getCurrentAuthenticatedUser(): firebase.User | null { + return this.auth.currentUser; + } + + getCurrentAuthenticatedUserOrThrow(): firebase.User { + if (this.auth.currentUser === null) { + throw new Error('Not authenticated yet.'); + } + return this.auth.currentUser; } getCurrentAuthenticatedUserDisplayName(): string { - const user = this.getCurrentAuthenticatedUser(); + const user = this.getCurrentAuthenticatedUserOrThrow(); let displayName: string | undefined | null = user.displayName; if (displayName) { return displayName; @@ -1597,7 +1604,7 @@ export class FirebaseProvider implements IStorage, IAuth { const now = new Date(); const buildableFirmware: IBuildableFirmware = { keyboardDefinitionId, - uid: this.getCurrentAuthenticatedUser()!.uid, + uid: this.getCurrentAuthenticatedUserOrThrow()!.uid, enabled: false, defaultBootloaderType: 'caterina', qmkFirmwareVersion: @@ -1903,7 +1910,7 @@ export class FirebaseProvider implements IStorage, IAuth { async fetchFirmwareBuildingTasks( keyboardDefinitionId: string ): Promise> { - if (this.getCurrentAuthenticatedUser() === null) { + if (this.getCurrentAuthenticatedUserOrThrow() === null) { return successResultOf([]); } try { @@ -1911,7 +1918,7 @@ export class FirebaseProvider implements IStorage, IAuth { .collection('build') .doc('v1') .collection('tasks') - .where('uid', '==', this.getCurrentAuthenticatedUser().uid) + .where('uid', '==', this.getCurrentAuthenticatedUserOrThrow().uid) .where('firmwareId', '==', keyboardDefinitionId) .orderBy('updatedAt', 'desc'); const querySnapshot = await query.get(); From c0ddf56b3e303767c0ee88bb9922c32231f557fb Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Fri, 15 Dec 2023 15:09:38 +0900 Subject: [PATCH 3/5] Send operation logs with uid. --- firestore.rules | 5 ++-- src/actions/hid.action.ts | 16 +++++++---- src/actions/storage.action.ts | 4 +-- src/actions/utils.ts | 19 +++++++++++++ src/components/common/auth/ProfileIcon.tsx | 6 ++-- src/components/configure/info/InfoDialog.tsx | 2 +- .../keymaplist/KeymapListPopover.tsx | 4 +-- src/components/keyboards/header/Header.tsx | 2 +- .../organizations/header/Header.tsx | 2 +- src/services/auth/Auth.ts | 4 +-- src/services/provider/Firebase.test.ts | 10 +++---- src/services/provider/Firebase.ts | 28 +++++++++---------- src/services/storage/Storage.ts | 1 + 13 files changed, 63 insertions(+), 40 deletions(-) diff --git a/firestore.rules b/firestore.rules index 9b864158..58846a0d 100644 --- a/firestore.rules +++ b/firestore.rules @@ -137,12 +137,11 @@ service cloud.firestore { && (request.auth.uid == resource.data.uid); } - match /logs/{definitionID}/operations/{operationLogID} { + match /logs/v1/operations/{operationLogID} { allow create; allow update: if false; allow delete: if false; - allow read: if isAuthenticated() - && (request.auth.uid == getDefinition(definitionID).data.author_uid); + allow read: if false; } function isAuthenticated() { diff --git a/src/actions/hid.action.ts b/src/actions/hid.action.ts index 934ee086..402ad64c 100644 --- a/src/actions/hid.action.ts +++ b/src/actions/hid.action.ts @@ -24,7 +24,7 @@ import { sendEventToGoogleAnalytics } from '../utils/GoogleAnalytics'; import { LayoutOption } from '../components/configure/keymap/Keymap'; import { maxValueByBitLength } from '../utils/NumberUtils'; import { KeyOp } from '../gen/types/KeyboardDefinition'; -import { getEncoderIdList } from './utils'; +import { getEncoderIdList, sendOperationLog } from './utils'; import { bmpKeyInfoList } from '../services/hid/KeycodeInfoListBmp'; const PRODUCT_PREFIX_FOR_BLE_MICRO_PRO = '(BMP)'; @@ -278,7 +278,7 @@ export const hidActionsThunk = { dispatch: ThunkDispatch, getState: () => RootState ) => { - const { app, entities, storage } = getState(); + const { app, entities, storage, auth } = getState(); const keyboard = entities.keyboard!; const result = await keyboard.open(); if (!result.success) { @@ -291,7 +291,10 @@ export const hidActionsThunk = { product_id: keyboard.getInformation().productId, product_name: keyboard.getInformation().productName, }); - await storage.instance!.sendOperationLog( + await sendOperationLog( + auth.instance!, + storage.instance!, + app.localAuthenticationInfo.uid, entities.keyboardDefinitionDocument!.id, 'configure/open' ); @@ -491,14 +494,17 @@ export const hidActionsThunk = { dispatch: ThunkDispatch, getState: () => RootState ) => { - const { app, entities, storage } = getState(); + const { app, entities, storage, auth } = getState(); const keyboard: IKeyboard = entities.keyboard!; sendEventToGoogleAnalytics('configure/flash', { vendor_id: keyboard.getInformation().vendorId, product_id: keyboard.getInformation().productId, product_name: keyboard.getInformation().productName, }); - await storage.instance!.sendOperationLog( + await sendOperationLog( + auth.instance!, + storage.instance!, + app.localAuthenticationInfo.uid, entities.keyboardDefinitionDocument!.id, 'configure/flash' ); diff --git a/src/actions/storage.action.ts b/src/actions/storage.action.ts index ec18b7bb..118f046c 100644 --- a/src/actions/storage.action.ts +++ b/src/actions/storage.action.ts @@ -601,7 +601,7 @@ export const storageActionsThunk = { ) => { const { storage, auth, keyboards, github } = getState(); const keyboardDefinition = keyboards.createdefinition.keyboardDefinition!; - const user = auth.instance!.getCurrentAuthenticatedUserOrThrow(); + const user = auth.instance!.getCurrentAuthenticatedUserIgnoreNull(); const githubProviderDataResult = getGitHubProviderData(user); if (!githubProviderDataResult.exists) { console.error('The user does not have a GitHub Provider data.'); @@ -673,7 +673,7 @@ export const storageActionsThunk = { const { storage, auth, keyboards, github } = getState(); const keyboardDefinition = keyboards.createdefinition.keyboardDefinition!; - const user = auth.instance!.getCurrentAuthenticatedUserOrThrow(); + const user = auth.instance!.getCurrentAuthenticatedUserIgnoreNull(); const githubProviderDataResutl = getGitHubProviderData(user); if (!githubProviderDataResutl.exists) { console.error('The user does not have a GitHub Provider data.'); diff --git a/src/actions/utils.ts b/src/actions/utils.ts index 0a0723cf..bef2115e 100644 --- a/src/actions/utils.ts +++ b/src/actions/utils.ts @@ -1,5 +1,7 @@ import { KeyOp } from '../gen/types/KeyboardDefinition'; import KeyboardModel from '../models/KeyboardModel'; +import { IAuth } from '../services/auth/Auth'; +import { IOperationLogType, IStorage } from '../services/storage/Storage'; export const getEncoderIdList = ( keymapDefinition: ((string | KeyOp)[] | { name: string })[] @@ -13,3 +15,20 @@ export const getEncoderIdList = ( return result; }, []); }; + +export const sendOperationLog = async ( + auth: IAuth, + storage: IStorage, + localAuthenticationUid: string, + keyboardDefinitionId: string, + operation: IOperationLogType +): Promise => { + let uid: string; + const user = auth.getCurrentAuthenticatedUserOrNull(); + if (user === null) { + uid = `local:${localAuthenticationUid}`; + } else { + uid = `firebase:${user.uid}`; + } + await storage.sendOperationLog(uid, keyboardDefinitionId, operation); +}; diff --git a/src/components/common/auth/ProfileIcon.tsx b/src/components/common/auth/ProfileIcon.tsx index 246341bd..09221d35 100644 --- a/src/components/common/auth/ProfileIcon.tsx +++ b/src/components/common/auth/ProfileIcon.tsx @@ -73,7 +73,7 @@ export default class ProfileIcon extends React.Component< } renderLinkGoogleAccountMenu() { - const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); + const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); if (user && !getGoogleProviderData(user).exists) { return ( { diff --git a/src/components/keyboards/header/Header.tsx b/src/components/keyboards/header/Header.tsx index 1e0f7154..4db726b3 100644 --- a/src/components/keyboards/header/Header.tsx +++ b/src/components/keyboards/header/Header.tsx @@ -41,7 +41,7 @@ class Header extends React.Component { } renderAvatarIcon() { - const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); + const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); if (user) { const { menuAnchorEl } = this.state; diff --git a/src/components/organizations/header/Header.tsx b/src/components/organizations/header/Header.tsx index fdfc219b..ce014495 100644 --- a/src/components/organizations/header/Header.tsx +++ b/src/components/organizations/header/Header.tsx @@ -41,7 +41,7 @@ class Header extends React.Component { } renderAvatarIcon() { - const user = this.props.auth!.getCurrentAuthenticatedUserOrThrow(); + const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); if (user) { const { menuAnchorEl } = this.state; diff --git a/src/services/auth/Auth.ts b/src/services/auth/Auth.ts index a29c63c1..54957b0d 100644 --- a/src/services/auth/Auth.ts +++ b/src/services/auth/Auth.ts @@ -16,8 +16,8 @@ export interface IAuth { linkToGitHub(): Promise; // eslint-disable-next-line no-unused-vars subscribeAuthStatus(callback: (user: firebase.User | null) => void): void; - getCurrentAuthenticatedUser(): firebase.User | null; - getCurrentAuthenticatedUserOrThrow(): firebase.User; + getCurrentAuthenticatedUserOrNull(): firebase.User | null; + getCurrentAuthenticatedUserIgnoreNull(): firebase.User; getCurrentAuthenticatedUserDisplayName(): string; signOut(): Promise; } diff --git a/src/services/provider/Firebase.test.ts b/src/services/provider/Firebase.test.ts index f2013562..8a612406 100644 --- a/src/services/provider/Firebase.test.ts +++ b/src/services/provider/Firebase.test.ts @@ -18,7 +18,7 @@ describe('FirebaseProvider', () => { describe('getCurrentAuthenticatedUserDisplayName', () => { test('user.displayName', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUserOrThrow(): firebase.User { + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { return { displayName: 'displayName1', } as firebase.User; @@ -33,7 +33,7 @@ describe('FirebaseProvider', () => { test('user.providerData[0].displayName', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUserOrThrow(): firebase.User { + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { return { displayName: null, providerData: [{ displayName: 'displayName2' }], @@ -49,7 +49,7 @@ describe('FirebaseProvider', () => { test('user.email', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUserOrThrow(): firebase.User { + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], @@ -66,7 +66,7 @@ describe('FirebaseProvider', () => { test('user.email', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUserOrThrow(): firebase.User { + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], @@ -83,7 +83,7 @@ describe('FirebaseProvider', () => { test('noname', () => { const subject: FirebaseProvider = { - getCurrentAuthenticatedUserOrThrow(): firebase.User { + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { return { displayName: null, providerData: [{ displayName: null }], diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index d54e1126..4ab427ba 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -619,19 +619,16 @@ export class FirebaseProvider implements IStorage, IAuth { ); } - getCurrentAuthenticatedUser(): firebase.User | null { + getCurrentAuthenticatedUserOrNull(): firebase.User | null { return this.auth.currentUser; } - getCurrentAuthenticatedUserOrThrow(): firebase.User { - if (this.auth.currentUser === null) { - throw new Error('Not authenticated yet.'); - } - return this.auth.currentUser; + getCurrentAuthenticatedUserIgnoreNull(): firebase.User { + return this.auth.currentUser!; } getCurrentAuthenticatedUserDisplayName(): string { - const user = this.getCurrentAuthenticatedUserOrThrow(); + const user = this.getCurrentAuthenticatedUserIgnoreNull(); let displayName: string | undefined | null = user.displayName; if (displayName) { return displayName; @@ -1604,7 +1601,7 @@ export class FirebaseProvider implements IStorage, IAuth { const now = new Date(); const buildableFirmware: IBuildableFirmware = { keyboardDefinitionId, - uid: this.getCurrentAuthenticatedUserOrThrow()!.uid, + uid: this.getCurrentAuthenticatedUserIgnoreNull()!.uid, enabled: false, defaultBootloaderType: 'caterina', qmkFirmwareVersion: @@ -1910,7 +1907,7 @@ export class FirebaseProvider implements IStorage, IAuth { async fetchFirmwareBuildingTasks( keyboardDefinitionId: string ): Promise> { - if (this.getCurrentAuthenticatedUserOrThrow() === null) { + if (this.getCurrentAuthenticatedUserIgnoreNull() === null) { return successResultOf([]); } try { @@ -1918,7 +1915,7 @@ export class FirebaseProvider implements IStorage, IAuth { .collection('build') .doc('v1') .collection('tasks') - .where('uid', '==', this.getCurrentAuthenticatedUserOrThrow().uid) + .where('uid', '==', this.getCurrentAuthenticatedUserIgnoreNull().uid) .where('firmwareId', '==', keyboardDefinitionId) .orderBy('updatedAt', 'desc'); const querySnapshot = await query.get(); @@ -2000,27 +1997,28 @@ export class FirebaseProvider implements IStorage, IAuth { } async sendOperationLog( + uid: string, keyboardDefinitionId: string, operation: IOperationLogType ): Promise { try { const doc: { + uid: string; + keyboardDefinitionId: string; operation: IOperationLogType; - uid?: string; createdAt: Date; expireAt: Date; } = { + uid, + keyboardDefinitionId, operation, createdAt: new Date(), // This operation log will be deleted after 90 days. expireAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), }; - if (this.auth.currentUser !== null) { - doc.uid = this.auth.currentUser.uid; - } await this.db .collection('logs') - .doc(keyboardDefinitionId) + .doc('v1') .collection('operations') .add(doc); } catch (error) { diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index 05d88906..ce0aaad0 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -535,6 +535,7 @@ export interface IStorage { fetchAllBuildableFirmwares(): Promise>; sendOperationLog( + uid: string, keyboardDefinitionId: string, operation: IOperationLogType ): Promise; From c0ecdf220ddf40b4ccf496450da31005611944d3 Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Mon, 18 Dec 2023 08:44:19 +0900 Subject: [PATCH 4/5] Add a new page to show statistics of the keyboard for keyboard designers. --- src/components/keyboards/content/Content.tsx | 1 + .../editdefinition/EditDefinition.tsx | 9 +++++++- .../statistics/Statistics.container.ts | 15 +++++++++++++ .../editdefinition/statistics/Statistics.scss | 9 ++++++++ .../editdefinition/statistics/Statistics.tsx | 21 +++++++++++++++++++ src/store/state.ts | 2 ++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/components/keyboards/editdefinition/statistics/Statistics.container.ts create mode 100644 src/components/keyboards/editdefinition/statistics/Statistics.scss create mode 100644 src/components/keyboards/editdefinition/statistics/Statistics.tsx diff --git a/src/components/keyboards/content/Content.tsx b/src/components/keyboards/content/Content.tsx index 9dbd7ad4..c0a33feb 100644 --- a/src/components/keyboards/content/Content.tsx +++ b/src/components/keyboards/content/Content.tsx @@ -51,6 +51,7 @@ function Contents(props: ContentsProps) { case KeyboardsPhase.catalog: case KeyboardsPhase.firmware: case KeyboardsPhase.build: + case KeyboardsPhase.statistics: return ; default: throw new Error( diff --git a/src/components/keyboards/editdefinition/EditDefinition.tsx b/src/components/keyboards/editdefinition/EditDefinition.tsx index 381a1ec2..327524e2 100644 --- a/src/components/keyboards/editdefinition/EditDefinition.tsx +++ b/src/components/keyboards/editdefinition/EditDefinition.tsx @@ -40,6 +40,7 @@ import DefinitionForm from './definitionform/DefinitionForm'; import CatalogForm from './catalogform/CatalogForm.container'; import FirmwareForm from './firmwareform/FirmwareForm.container'; import BuildForm from './buildform/BuildForm.container'; +import Statistics from './statistics/Statistics.container'; type ConfirmDialogMode = | 'save_as_draft' @@ -207,6 +208,8 @@ export default function EditDefinition(props: EditKeyboardProps) { props.updatePhase!('firmware'); } else if (tabIndex === 3) { props.updatePhase!('build'); + } else if (tabIndex === 4) { + props.updatePhase!('statistics'); } else { throw new Error(`Invalid tabIndex: ${tabIndex}`); } @@ -280,7 +283,9 @@ export default function EditDefinition(props: EditKeyboardProps) { ? 1 : props.phase === 'firmware' ? 2 - : 3 + : props.phase === 'build' + ? 3 + : 4 } indicatorColor="primary" textColor="primary" @@ -292,6 +297,7 @@ export default function EditDefinition(props: EditKeyboardProps) { + ) : null} @@ -361,6 +367,7 @@ export default function EditDefinition(props: EditKeyboardProps) { {props.phase === 'catalog' ? : null} {props.phase === 'firmware' ? : null} {props.phase === 'build' ? : null} + {props.phase === 'statistics' ? : null} diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.container.ts b/src/components/keyboards/editdefinition/statistics/Statistics.container.ts new file mode 100644 index 00000000..36b377c9 --- /dev/null +++ b/src/components/keyboards/editdefinition/statistics/Statistics.container.ts @@ -0,0 +1,15 @@ +import { RootState } from '../../../../store/state'; +import { connect } from 'react-redux'; +import Statistics from './Statistics'; + +const mapStateToProps = (state: RootState) => { + return {}; +}; +export type StatisticsStateType = ReturnType; + +const mapDispatchToProps = (_dispatch: any) => { + return {}; +}; +export type StatisticsActionsType = ReturnType; + +export default connect(mapStateToProps, mapDispatchToProps)(Statistics); diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.scss b/src/components/keyboards/editdefinition/statistics/Statistics.scss new file mode 100644 index 00000000..4abdbc5c --- /dev/null +++ b/src/components/keyboards/editdefinition/statistics/Statistics.scss @@ -0,0 +1,9 @@ +@import 'src/variables'; + +.edit-definition-statistics { + &-container { + display: flex; + flex-direction: row; + width: 100%; + } +} diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.tsx b/src/components/keyboards/editdefinition/statistics/Statistics.tsx new file mode 100644 index 00000000..f2c5522b --- /dev/null +++ b/src/components/keyboards/editdefinition/statistics/Statistics.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { + StatisticsActionsType, + StatisticsStateType, +} from './Statistics.container'; +import './Statistics.scss'; +import { Card } from '@mui/material'; + +type OwnProps = {}; +type StatisticsProps = OwnProps & + Partial & + Partial; + +export default function Statistics(props: StatisticsProps) { + return ( +
+ + +
+ ); +} diff --git a/src/store/state.ts b/src/store/state.ts index 3e31454b..a323a604 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -63,6 +63,7 @@ export type IKeyboardsPhase = | 'catalog' | 'firmware' | 'build' + | 'statistics' | 'signout'; export const KeyboardsPhase: { [p: string]: IKeyboardsPhase } = { signing: 'signing', @@ -74,6 +75,7 @@ export const KeyboardsPhase: { [p: string]: IKeyboardsPhase } = { catalog: 'catalog', firmware: 'firmware', build: 'build', + statistics: 'statistics', signout: 'signout', }; From 804cef2c2e0f065375323d8beda8ef9337dd567e Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Mon, 18 Dec 2023 17:00:04 +0900 Subject: [PATCH 5/5] Fetch keyboard statistics and show them. --- package.json | 1 + src/actions/keyboards.actions.ts | 8 ++ src/actions/storage.action.ts | 17 +++++ .../statistics/Statistics.container.ts | 5 +- .../editdefinition/statistics/Statistics.scss | 2 +- .../editdefinition/statistics/Statistics.tsx | 73 ++++++++++++++++++- src/services/provider/Firebase.ts | 27 +++++++ src/services/storage/Storage.ts | 16 ++++ src/store/reducers.ts | 4 + src/store/state.ts | 3 + yarn.lock | 5 ++ 11 files changed, 156 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1ea761c6..8155f100 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react": "^17.0.1", "react-dom": "^17.0.1", "react-draggable": "^4.4.3", + "react-google-charts": "^4.0.1", "react-helmet-async": "^1.0.9", "react-image-gallery": "^1.2.7", "react-redux": "^7.2.2", diff --git a/src/actions/keyboards.actions.ts b/src/actions/keyboards.actions.ts index 333f2a69..a6c36db9 100644 --- a/src/actions/keyboards.actions.ts +++ b/src/actions/keyboards.actions.ts @@ -14,6 +14,7 @@ import { IBuildableFirmwareFileType, IKeyboardDefinitionAuthorType, IKeyboardDefinitionDocument, + IKeyboardStatistics, IStore, } from '../services/storage/Storage'; import { ThunkAction, ThunkDispatch } from 'redux-thunk'; @@ -196,6 +197,7 @@ export const KEYBOARDS_EDIT_DEFINITION_UPDATE_ORGANIZATION_ID = `${KEYBOARDS_EDI export const KEYBOARDS_EDIT_DEFINITION_UPDATE_AUTHOR_TYPE = `${KEYBOARDS_EDIT_DEFINITION_ACTIONS}/UpdateAuthorType`; export const KEYBOARDS_EDIT_DEFINITION_UPDATE_BUILDABLE_FIRMWARE_FILE = `${KEYBOARDS_EDIT_DEFINITION_ACTIONS}/UpdateBuildableFirmwareFile`; export const KEYBOARDS_EDIT_DEFINITION_UPDATE_BUILDABLE_FIRMWARE_CODE_PARAMETERS = `${KEYBOARDS_EDIT_DEFINITION_ACTIONS}/UpdateBuildableFirmwareCodeParameters`; +export const KEYBOARDS_EDIT_DEFINITION_UPDATE_KEYBOARD_STATISTICS = `${KEYBOARDS_EDIT_DEFINITION_ACTIONS}/UpdateKeyboardStatistics`; export const KeyboardsEditDefinitionActions = { clear: () => { @@ -447,6 +449,12 @@ export const KeyboardsEditDefinitionActions = { value: parameters, }; }, + updateKeyboardStatistics: (statistics: IKeyboardStatistics | undefined) => { + return { + type: KEYBOARDS_EDIT_DEFINITION_UPDATE_KEYBOARD_STATISTICS, + value: statistics, + }; + }, }; type ActionTypes = ReturnType< diff --git a/src/actions/storage.action.ts b/src/actions/storage.action.ts index 118f046c..04f485de 100644 --- a/src/actions/storage.action.ts +++ b/src/actions/storage.action.ts @@ -335,6 +335,23 @@ export const storageActionsThunk = { ); return; } + const fetchKeyboardStatisticsResult = + await storage.instance!.fetchKeyboardStatistics(definitionId); + if (isError(fetchKeyboardStatisticsResult)) { + console.error(fetchKeyboardStatisticsResult.cause); + dispatch( + NotificationActions.addError( + fetchKeyboardStatisticsResult.error, + fetchKeyboardStatisticsResult.cause + ) + ); + return; + } + dispatch( + KeyboardsEditDefinitionActions.updateKeyboardStatistics( + fetchKeyboardStatisticsResult.value + ) + ); dispatch( StorageActions.updateBuildableFirmware( fetchBuildableFirmwareResult.value diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.container.ts b/src/components/keyboards/editdefinition/statistics/Statistics.container.ts index 36b377c9..2deaad45 100644 --- a/src/components/keyboards/editdefinition/statistics/Statistics.container.ts +++ b/src/components/keyboards/editdefinition/statistics/Statistics.container.ts @@ -3,10 +3,13 @@ import { connect } from 'react-redux'; import Statistics from './Statistics'; const mapStateToProps = (state: RootState) => { - return {}; + return { + statistics: state.keyboards.editdefinition.keyboardStatistics, + }; }; export type StatisticsStateType = ReturnType; +// eslint-disable-next-line no-unused-vars const mapDispatchToProps = (_dispatch: any) => { return {}; }; diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.scss b/src/components/keyboards/editdefinition/statistics/Statistics.scss index 4abdbc5c..e28e4c6f 100644 --- a/src/components/keyboards/editdefinition/statistics/Statistics.scss +++ b/src/components/keyboards/editdefinition/statistics/Statistics.scss @@ -3,7 +3,7 @@ .edit-definition-statistics { &-container { display: flex; - flex-direction: row; + flex-direction: column; width: 100%; } } diff --git a/src/components/keyboards/editdefinition/statistics/Statistics.tsx b/src/components/keyboards/editdefinition/statistics/Statistics.tsx index f2c5522b..e0c842b1 100644 --- a/src/components/keyboards/editdefinition/statistics/Statistics.tsx +++ b/src/components/keyboards/editdefinition/statistics/Statistics.tsx @@ -4,7 +4,8 @@ import { StatisticsStateType, } from './Statistics.container'; import './Statistics.scss'; -import { Card } from '@mui/material'; +import { Card, CardContent, Typography } from '@mui/material'; +import { Chart } from 'react-google-charts'; type OwnProps = {}; type StatisticsProps = OwnProps & @@ -12,10 +13,76 @@ type StatisticsProps = OwnProps & Partial; export default function Statistics(props: StatisticsProps) { + const data = props.statistics; + return (
- - + + + + Counts of opening a keyboard per day + + {data !== undefined && ( + [ + label, + data.statistics.counts_of_opening_keyboard.values[index], + ] + ), + ]} + width="100%" + height="300px" + options={{ + legend: 'none', + }} + /> + )} + + * This statistics will be shown after logs are collected by enough + users because of avoiding a privacy issue. + + + + + + + Counts of flashing a keymap to MCU + + {data !== undefined && ( + [ + label, + data.statistics.counts_of_flashing_keymap.values[index], + ] + ), + ]} + width="100%" + height="300px" + options={{ + legend: 'none', + }} + /> + )} + + * This statistics will be shown after logs are collected by enough + users because of avoiding a privacy issue. + + +
); } diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 4ab427ba..d639fb78 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -44,6 +44,7 @@ import { BUILDABLE_FIRMWARE_QMK_FIRMWARE_VERSION, IBuildableFirmwareQmkFirmwareVersion, IOperationLogType, + IKeyboardStatistics, } from '../storage/Storage'; import { IAuth, IAuthenticationResult } from '../auth/Auth'; import { IFirmwareCodePlace, IKeyboardFeatures } from '../../store/state'; @@ -2026,4 +2027,30 @@ export class FirebaseProvider implements IStorage, IAuth { // Ignore error. } } + + async fetchKeyboardStatistics( + keyboardDefinitionId: string + ): Promise> { + try { + const createKeyboardStatistics = this.functions.httpsCallable( + 'createKeyboardStatistics' + ); + const createKeyboardStatisticsResult = await createKeyboardStatistics({ + keyboardDefinitionId, + }); + const data = createKeyboardStatisticsResult.data; + if (data.success) { + return successResultOf(data); + } else { + console.error(data.errorMessage); + return errorResultOf(data.errorMessage); + } + } catch (error) { + console.error(error); + return errorResultOf( + `Fetching keyboard statistics failed: ${error}`, + error + ); + } + } } diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index ce0aaad0..fdde8148 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -318,6 +318,19 @@ export type IFirmwareBuildingTask = { export type IOperationLogType = 'configure/flash' | 'configure/open'; +export type IKeyboardStatistics = { + statistics: { + counts_of_opening_keyboard: { + labels: string[]; + values: number[]; + }; + counts_of_flashing_keymap: { + labels: string[]; + values: number[]; + }; + }; +}; + /* eslint-disable no-unused-vars */ export interface IStorage { fetchKeyboardDefinitionDocumentByDeviceInfo( @@ -539,5 +552,8 @@ export interface IStorage { keyboardDefinitionId: string, operation: IOperationLogType ): Promise; + fetchKeyboardStatistics( + keyboardDefinitionId: string + ): Promise>; } /* eslint-enable no-unused-vars */ diff --git a/src/store/reducers.ts b/src/store/reducers.ts index 1f7c7c7f..4248cfc4 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -143,6 +143,7 @@ import { KEYBOARDS_EDIT_DEFINITION_UPDATE_JSON_FILENAME, KEYBOARDS_EDIT_DEFINITION_UPDATE_JSON_STRING, KEYBOARDS_EDIT_DEFINITION_UPDATE_KEYBOARD_DEFINITION, + KEYBOARDS_EDIT_DEFINITION_UPDATE_KEYBOARD_STATISTICS, KEYBOARDS_EDIT_DEFINITION_UPDATE_MAIN_IMAGE_UPLOADED_RATE, KEYBOARDS_EDIT_DEFINITION_UPDATE_MAIN_IMAGE_UPLOADING, KEYBOARDS_EDIT_DEFINITION_UPDATE_ORGANIZATION_EVIDENCE, @@ -455,6 +456,9 @@ const keyboardsEditKeyboardReducer = ( draft.keyboards.editdefinition.buildableFirmwareCodeParameters = action.value; break; + case KEYBOARDS_EDIT_DEFINITION_UPDATE_KEYBOARD_STATISTICS: + draft.keyboards.editdefinition.keyboardStatistics = action.value; + break; } }; diff --git a/src/store/state.ts b/src/store/state.ts index a323a604..adea8506 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -14,6 +14,7 @@ import { IFirmwareBuildingTask, IKeyboardDefinitionAuthorType, IKeyboardDefinitionDocument, + IKeyboardStatistics, IOrganization, IOrganizationMember, IStorage, @@ -411,6 +412,7 @@ export type RootState = { buildableFirmwareFile: IBuildableFirmwareFile | null; buildableFirmwareFileType: IBuildableFirmwareFileType | null; buildableFirmwareCodeParameters: IBuildableFirmwareCodeParameter[]; + keyboardStatistics: IKeyboardStatistics | undefined; }; }; catalog: { @@ -671,6 +673,7 @@ export const INIT_STATE: RootState = { buildableFirmwareFile: null, buildableFirmwareFileType: null, buildableFirmwareCodeParameters: [], + keyboardStatistics: undefined, }, }, catalog: { diff --git a/yarn.lock b/yarn.lock index a51a5bb9..754d83e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12926,6 +12926,11 @@ react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-google-charts@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/react-google-charts/-/react-google-charts-4.0.1.tgz#b7713856a48009b77318f8951ddf2c3ba39f991b" + integrity sha512-V/hcMcNuBgD5w49BYTUDye+bUKaPmsU5vy/9W/Nj2xEeGn+6/AuH9IvBkbDcNBsY00cV9OeexdmgfI5RFHgsXQ== + react-helmet-async@*, react-helmet-async@^1.0.7, react-helmet-async@^1.0.9: version "1.2.3" resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.2.3.tgz#57326a69304ea3293036eafb49475e9ba454cb37"