Reupload
This commit is contained in:
commit
61cbd57af1
168 changed files with 31208 additions and 0 deletions
36
.github/workflows/github-actions-test.yml
vendored
Normal file
36
.github/workflows/github-actions-test.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
name: Run tests
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18.x]
|
||||||
|
mongodb-version: ['6.0']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Git checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
- name: Start MongoDB
|
||||||
|
uses: supercharge/mongodb-github-action@1.8.0
|
||||||
|
with:
|
||||||
|
mongodb-version: ${{ matrix.mongodb-version }}
|
||||||
|
mongodb-username: DevUser
|
||||||
|
mongodb-password: DevPassword
|
||||||
|
mongodb-db: awary-test
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make server-setup
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test
|
||||||
|
env:
|
||||||
|
CI: true
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
server/node_modules
|
||||||
|
server/dist
|
||||||
|
server/db
|
||||||
|
server/db-test
|
||||||
|
server/conf.env
|
||||||
|
server/requests/.env
|
||||||
|
|
||||||
|
web-app/node_modules
|
||||||
|
web-app/build
|
||||||
|
|
||||||
|
docs/site
|
||||||
|
|
||||||
|
.nvimrc
|
661
LICENSE
Normal file
661
LICENSE
Normal file
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
45
Makefile
Normal file
45
Makefile
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
.PHONY: db server-dev server server-setup web-app-dev web-app-build web-app-setup web-app test docs
|
||||||
|
|
||||||
|
UIDS:= USER_ID=$(shell id -u) GROUP_ID=$(shell id -g)
|
||||||
|
ENV_VARS:= set -a && . ./conf.env && ${UIDS} && set +a
|
||||||
|
|
||||||
|
# Database
|
||||||
|
|
||||||
|
db:
|
||||||
|
cd server && mkdir -p db && bash podman-mongodb.sh
|
||||||
|
|
||||||
|
db-test:
|
||||||
|
cd server && bash podman-mongodb-test.sh
|
||||||
|
|
||||||
|
# Server
|
||||||
|
|
||||||
|
server-setup:
|
||||||
|
cd server && npm install
|
||||||
|
|
||||||
|
server-dev:
|
||||||
|
cd server && npm run dev
|
||||||
|
|
||||||
|
server:
|
||||||
|
cd server && npm run start:prod
|
||||||
|
|
||||||
|
test: db-test
|
||||||
|
cd server && npm run test
|
||||||
|
|
||||||
|
# Web app
|
||||||
|
|
||||||
|
web-app-setup:
|
||||||
|
cd web-app && npm install
|
||||||
|
|
||||||
|
web-app-dev:
|
||||||
|
cd web-app && npm start
|
||||||
|
|
||||||
|
web-app-build:
|
||||||
|
cd web-app && npm run build
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
cd web-app && npm run build && npx serve -s build
|
||||||
|
|
||||||
|
# User documentation
|
||||||
|
|
||||||
|
docs:
|
||||||
|
cd docs && python -m mkdocs build
|
46
README.md
Normal file
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
**This software is under development and not stable**
|
||||||
|
|
||||||
|
Awary is a simple software that allows you to **store** data in the form of a **log** or **metric**
|
||||||
|
using HTTP requests, there is no processing of these data.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Documentation is available in the /docs directory as markdown files.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Add logs and update metrics with a simple HTTP request.
|
||||||
|
- Logs support tags.
|
||||||
|
- Each metric update is kept in an history.
|
||||||
|
- Customize your dashboard with charts or simple numbers.
|
||||||
|
- Multiple API keys per project.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
*Note: the repository has a docker-compose file for the MongoDB database but you can connect to your own instance if you want.*
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js & npm
|
||||||
|
- make
|
||||||
|
- (optional) docker & docker-compose
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Copy the server config example `server/conf-example.env` to `server/conf.env`, then edit it to fill the required fields, everything should be documented inside the file itself.
|
||||||
|
2. Edit the web app config file `web-app/.env.production` and change the `REACT_APP_API_URL` to match the location of the server.
|
||||||
|
|
||||||
|
### Launch it
|
||||||
|
|
||||||
|
1. `make db` to launch MongoDB with docker (optional).
|
||||||
|
2. `make server-setup` to install the server dependencies (only the first time).
|
||||||
|
3. `make web-app-setup` to install the web app dependencies (only the first time).
|
||||||
|
4. `make server` to launch the server.
|
||||||
|
5. `make web-app` to launch the web app.
|
||||||
|
|
||||||
|
## Building the documentation
|
||||||
|
|
||||||
|
1. Install **make**, **python** and **pip** if you don't already have them.
|
||||||
|
2. Install mkdocs with `pip install mkdocs`.
|
||||||
|
3. Install mkdocs material theme with `pip install mkdocs-material`.
|
||||||
|
4. Build the docs with `make docs`.
|
57
docs/docs/Logs.md
Normal file
57
docs/docs/Logs.md
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
Logs are text with a date and tags associated with it.
|
||||||
|
|
||||||
|
## API Calls
|
||||||
|
|
||||||
|
You need 2 things to make an api call to create a log:
|
||||||
|
|
||||||
|
- An API Key (generate one from the "Api keys" tab on the left).
|
||||||
|
- The project's `id`, you can copy it by clicking on the clipboard icon at the top of the page.
|
||||||
|
|
||||||
|
### Add a log
|
||||||
|
|
||||||
|
Method: `POST`
|
||||||
|
Url: `/projects/{PROJECT_ID}/logs`
|
||||||
|
Header:
|
||||||
|
```json
|
||||||
|
Authorization: Bearer {API_KEY}
|
||||||
|
```
|
||||||
|
Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "The title",
|
||||||
|
"content": "More information",
|
||||||
|
"tags": [123456789098765432123456]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `tags` is an array of `id`, you can find the `id` on the tags config panel on the Logs page.
|
||||||
|
|
||||||
|
### Retrieving all logs
|
||||||
|
|
||||||
|
Method: `GET`
|
||||||
|
Url: `/projects/{PROJECT_ID}/logs`
|
||||||
|
Header:
|
||||||
|
```json
|
||||||
|
Authorization: Bearer {API_KEY}
|
||||||
|
```
|
||||||
|
Response body example:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "647a148a4f68451001cec326",
|
||||||
|
"projectId": "647a146d4f68451001cec323",
|
||||||
|
"title": "The title",
|
||||||
|
"content": "More information",
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"id": "647a148e4f68451001cec328",
|
||||||
|
"projectId": "647a146d4f68451001cec323",
|
||||||
|
"name": 1685722254142,
|
||||||
|
"color": "#ff00ff"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
87
docs/docs/Metrics.md
Normal file
87
docs/docs/Metrics.md
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
# Metrics
|
||||||
|
|
||||||
|
Metrics are an easy way to keep track of how things evolve.
|
||||||
|
|
||||||
|
## Use case
|
||||||
|
|
||||||
|
Each time a value change on your app or something else you want to track, you can call the api to
|
||||||
|
update the value, all previous values are stored in a database and can be viewed in the
|
||||||
|
form of a graph on the official web app, or you can call the api to retrieve the data and make your
|
||||||
|
own thing.
|
||||||
|
|
||||||
|
## API Calls
|
||||||
|
|
||||||
|
You need 3 things to make an api call for a metric:
|
||||||
|
|
||||||
|
- An API Key (check this to know how to generate one).
|
||||||
|
- The project's `id`, you can copy it by clicking on the clipboard icon next to the project's name.
|
||||||
|
- The metric's `id`, you can copy it by clicking on the clipboard icon next to the metric's name.
|
||||||
|
|
||||||
|
### Updating a metric
|
||||||
|
|
||||||
|
Method: `PUT`
|
||||||
|
Url: `/projects/{PROJECT_ID}/metrics/{METRIC_ID}`
|
||||||
|
Header:
|
||||||
|
```json
|
||||||
|
Authorization: Bearer {API_KEY}
|
||||||
|
```
|
||||||
|
Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
value: 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrieving one metric
|
||||||
|
|
||||||
|
Method: `GET`
|
||||||
|
Url: `/projects/{PROJECT_ID}/metrics/{METRIC_ID}`
|
||||||
|
Header:
|
||||||
|
```json
|
||||||
|
Authorization: Bearer {API_KEY}
|
||||||
|
```
|
||||||
|
Response body example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "647a148a4f68451001cec326",
|
||||||
|
"projectId": "647a146d4f68451001cec323",
|
||||||
|
"name": "My metric name",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": "647a148e4f68451001cec328",
|
||||||
|
"metricId": "647a148a4f68451001cec326",
|
||||||
|
"date": 1685722254142,
|
||||||
|
"value": 33
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentValue": 33
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrieving all metrics
|
||||||
|
|
||||||
|
Method: `GET`
|
||||||
|
Url: `/projects/{PROJECT_ID}/metrics`
|
||||||
|
Header:
|
||||||
|
```json
|
||||||
|
Authorization: Bearer {API_KEY}
|
||||||
|
```
|
||||||
|
Response body example:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "647a148a4f68451001cec326",
|
||||||
|
"projectId": "647a146d4f68451001cec323",
|
||||||
|
"name": "My metric name",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": "647a148e4f68451001cec328",
|
||||||
|
"metricId": "647a148a4f68451001cec326",
|
||||||
|
"date": 1685722254142,
|
||||||
|
"value": 33
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentValue": 33
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
5
docs/docs/index.md
Normal file
5
docs/docs/index.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# Documentation for Awary
|
||||||
|
|
||||||
|
Awary is a simple software that allows you to **store** data in the form of a **log** or **metric**
|
||||||
|
using HTTP requests, there is no processing of these data.
|
||||||
|
|
27
docs/mkdocs.yml
Normal file
27
docs/mkdocs.yml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
site_name: Awary documentation
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
palette:
|
||||||
|
|
||||||
|
# Palette toggle for light mode
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-night
|
||||||
|
name: Switch to dark mode
|
||||||
|
|
||||||
|
# Palette toggle for dark mode
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-sunny
|
||||||
|
name: Switch to light mode
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
line_spans: __span
|
||||||
|
pygments_lang_class: true
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.snippets
|
||||||
|
- pymdownx.superfences
|
BIN
images/presentation.png
Normal file
BIN
images/presentation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 177 KiB |
12
server/.eslintrc.json
Normal file
12
server/.eslintrc.json
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"root": true,
|
||||||
|
"rules": {
|
||||||
|
"indent": ["error", "tab"],
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"object-curly-spacing": "error"
|
||||||
|
}
|
||||||
|
}
|
1
server/README.md
Normal file
1
server/README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# awary-server
|
25
server/conf-example.env
Normal file
25
server/conf-example.env
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
ENV=production
|
||||||
|
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=27017
|
||||||
|
DB_USER=replace-me
|
||||||
|
DB_PASSWORD=replace-me
|
||||||
|
DB_NAME=awary
|
||||||
|
|
||||||
|
# For the following variables, you might want some long random string (if you have openssl installed you can use `openssl rand -base64 64`)
|
||||||
|
JWT_SECRET=replace-me
|
||||||
|
API_ADMIN_AUTHORIZATION=replace-me
|
||||||
|
|
||||||
|
ENABLE_USER_REGISTRATION=true
|
||||||
|
RATE_LIMIT_ENABLED=false
|
||||||
|
|
||||||
|
# Uncomment to enable
|
||||||
|
#
|
||||||
|
# MAX_ACCOUNT=100
|
||||||
|
# MAX_PROJECT_PER_ACCOUNT=4
|
||||||
|
# MAX_METRIC_PER_PROJECT=5
|
||||||
|
# METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||||
|
# METRICS_HISTORY_LENGTH=1000
|
24
server/conf-test.env
Normal file
24
server/conf-test.env
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
ENV=TEST
|
||||||
|
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8081
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=27018
|
||||||
|
DB_USER=DevUser
|
||||||
|
DB_PASSWORD=DevPassword
|
||||||
|
DB_NAME=awary-test
|
||||||
|
|
||||||
|
JWT_SECRET=ujebxzQ1qZC/jAFBXnVk/KfKwqJd8U+zDo3ZX3ejWAC26XaV1J9kjxa4CGvkMpTxvhFaFBPZoL5DdKnWWEDfVQ==
|
||||||
|
API_ADMIN_AUTHORIZATION=cM6FB3X27THShaNniHwF4bewRc8QFGe0/CIOOPiwcJLa3jtRhIjcTEVW/zPOKDrR6e5kMv+j0kFgXmDjrM/hWA==
|
||||||
|
|
||||||
|
ENABLE_USER_REGISTRATION=true
|
||||||
|
RATE_LIMIT_ENABLED=false
|
||||||
|
|
||||||
|
MAX_ACCOUNT=100
|
||||||
|
|
||||||
|
MAX_PROJECT_PER_ACCOUNT=4
|
||||||
|
|
||||||
|
MAX_METRIC_PER_PROJECT=5
|
||||||
|
METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||||
|
METRICS_HISTORY_LENGTH=1000
|
15
server/create-user.sh
Normal file
15
server/create-user.sh
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
. ./conf.env
|
||||||
|
|
||||||
|
BASE_URL=$1
|
||||||
|
EMAIL=$2
|
||||||
|
PASSWORD=`openssl rand -base64 16 | sed -e "s/=//g"`
|
||||||
|
|
||||||
|
echo $API_ADMIN_AUTHORIZATION
|
||||||
|
echo $1
|
||||||
|
echo $PASSWORD
|
||||||
|
|
||||||
|
curl -X POST $BASE_URL/signup \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\", \"adminToken\": \"$API_ADMIN_AUTHORIZATION\"}"
|
5240
server/package-lock.json
generated
Normal file
5240
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
server/package.json
Normal file
43
server/package.json
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@sinclair/typebox": "^0.23.5",
|
||||||
|
"@types/chai": "^4.3.1",
|
||||||
|
"@types/mocha": "^9.1.1",
|
||||||
|
"@types/node": "^18.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.4",
|
||||||
|
"@types/sinon": "^10.0.11",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||||
|
"@typescript-eslint/parser": "^5.54.0",
|
||||||
|
"chai": "^4.3.6",
|
||||||
|
"env-cmd": "^10.1.0",
|
||||||
|
"eslint": "^8.35.0",
|
||||||
|
"mocha": "^10.2.0",
|
||||||
|
"mocha-better-spec-reporter": "^3.1.0",
|
||||||
|
"nodemon": "^2.0.16",
|
||||||
|
"sinon": "^14.0.0",
|
||||||
|
"ts-mocha": "^10.0.0",
|
||||||
|
"ts-node": "^10.8.1",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"tsc-alias": "^1.8.2",
|
||||||
|
"tsc-watch": "^6.0.0",
|
||||||
|
"tsconfig-paths": "^4.0.0",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^8.0.0",
|
||||||
|
"@fastify/jwt": "^6.1.0",
|
||||||
|
"@fastify/type-provider-typebox": "^1.0.0",
|
||||||
|
"argon2": "^0.28.5",
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"fastify": "^4.0.3",
|
||||||
|
"mongodb": "^4.7.0",
|
||||||
|
"nodemailer": "^6.7.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "LOGGER_ENABLED=1 env-cmd -f conf.env nodemon --watch './**/*.ts' --exec node --inspect=0.0.0.0 -r ts-node/register src/index.ts",
|
||||||
|
"start:prod": "LOGGER_ENABLED=1 env-cmd -f conf.env node -r ts-node/register src/index.ts",
|
||||||
|
"test": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register --exit 'src/**/*.test.ts'",
|
||||||
|
"test-file": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register",
|
||||||
|
"lint": "eslint src"
|
||||||
|
}
|
||||||
|
}
|
12
server/podman-mongodb-test.sh
Normal file
12
server/podman-mongodb-test.sh
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
source ./conf-test.env
|
||||||
|
|
||||||
|
mkdir -p ./db-test
|
||||||
|
podman stop awary_mongodb_test
|
||||||
|
podman rm awary_mongodb_test
|
||||||
|
podman run -dt -p 27018:27017 \
|
||||||
|
--name awary_mongodb_test \
|
||||||
|
--userns keep-id \
|
||||||
|
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||||
|
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||||
|
-v ./db-test:/data/db \
|
||||||
|
docker.io/library/mongo:5.0
|
13
server/podman-mongodb.sh
Normal file
13
server/podman-mongodb.sh
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
source ./conf.env
|
||||||
|
|
||||||
|
mkdir -p ./db
|
||||||
|
mkdir -p ./backup
|
||||||
|
podman stop awary_mongodb
|
||||||
|
podman rm awary_mongodb
|
||||||
|
podman run -dt -p 27017:27017 \
|
||||||
|
--name awary_mongodb \
|
||||||
|
--userns keep-id \
|
||||||
|
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||||
|
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||||
|
-v ./db:/data/db \
|
||||||
|
docker.io/library/mongo:5.0
|
3
server/requests/.env-example
Normal file
3
server/requests/.env-example
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
API_ADMIN_AUTHORIZATION=replace-me ## Must match the conf.env
|
||||||
|
BEARER_TOKEN=random_string
|
||||||
|
HOST=127.0.0.1:8080
|
36
server/requests/login.http
Normal file
36
server/requests/login.http
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
### LOGIN
|
||||||
|
|
||||||
|
POST http://{{HOST}}/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user1@email.com",
|
||||||
|
"password": "123456"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{%
|
||||||
|
local body = context.json_decode(context.result.body)
|
||||||
|
context.set_env("BEARER_TOKEN", body.token)
|
||||||
|
%}
|
||||||
|
|
||||||
|
### SIGNUP
|
||||||
|
|
||||||
|
POST http://{{HOST}}/signup
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user1@email.com",
|
||||||
|
"password": "123456"
|
||||||
|
}
|
||||||
|
|
||||||
|
### FORCE CHANGE PASSWORD
|
||||||
|
|
||||||
|
POST http://{{HOST}}/admin/change-user-password
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{API_ADMIN_AUTHORIZATION}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user1@email.com",
|
||||||
|
"newPassword": "1234567"
|
||||||
|
}
|
56
server/src/core/App.ts
Normal file
56
server/src/core/App.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {Db} from "mongodb";
|
||||||
|
import {UserFeature} from "./features/users";
|
||||||
|
import {ProjectFeature} from "./features/projects";
|
||||||
|
import {MetricFeature} from "./features/metrics";
|
||||||
|
import {LogFeature} from "./features/logs";
|
||||||
|
import {ActivityLoggerFeature} from "./features/activityLogger";
|
||||||
|
import {ViewsFeature} from "./features/views";
|
||||||
|
import {Logger} from "@app/utils/logger";
|
||||||
|
import {ServerAdminFeature} from "./features/serverAdmin";
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
|
||||||
|
db?: Db
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
logFeature: LogFeature
|
||||||
|
metricFeature: MetricFeature
|
||||||
|
viewsFeature: ViewsFeature
|
||||||
|
activityLogger: ActivityLoggerFeature
|
||||||
|
serverAdminFeature: ServerAdminFeature
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
const services = {db}
|
||||||
|
this.userFeature = new UserFeature(services);
|
||||||
|
this.projectFeature = new ProjectFeature(services, {
|
||||||
|
userFeature: this.userFeature
|
||||||
|
});
|
||||||
|
this.logFeature = new LogFeature(services, {
|
||||||
|
userFeature: this.userFeature,
|
||||||
|
projectFeature: this.projectFeature
|
||||||
|
})
|
||||||
|
this.metricFeature = new MetricFeature(services, {
|
||||||
|
userFeature: this.userFeature,
|
||||||
|
projectFeature: this.projectFeature
|
||||||
|
})
|
||||||
|
this.viewsFeature = new ViewsFeature(services, {
|
||||||
|
metricFeature: this.metricFeature
|
||||||
|
})
|
||||||
|
this.activityLogger = new ActivityLoggerFeature(services, {
|
||||||
|
userFeature: this.userFeature,
|
||||||
|
projectFeature: this.projectFeature,
|
||||||
|
logFeature: this.logFeature,
|
||||||
|
metricFeature: this.metricFeature
|
||||||
|
})
|
||||||
|
this.serverAdminFeature = new ServerAdminFeature(services, {
|
||||||
|
userFeature: this.userFeature,
|
||||||
|
projectFeature: this.projectFeature,
|
||||||
|
logFeature: this.logFeature,
|
||||||
|
metricFeature: this.metricFeature
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
Logger.info("Starting application")
|
||||||
|
}
|
||||||
|
}
|
20
server/src/core/Feature.ts
Normal file
20
server/src/core/Feature.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import {Db} from "mongodb";
|
||||||
|
import {UserFeature} from "./features/users";
|
||||||
|
|
||||||
|
export type AppServices = {
|
||||||
|
db: Db
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppFeatures = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Feature {
|
||||||
|
|
||||||
|
public abstract name: string
|
||||||
|
protected services: AppServices
|
||||||
|
|
||||||
|
constructor(services: AppServices) {
|
||||||
|
this.services = services
|
||||||
|
}
|
||||||
|
}
|
11
server/src/core/FeatureEvent.ts
Normal file
11
server/src/core/FeatureEvent.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export class FeatureEvent<T> {
|
||||||
|
callbacks: ((data: T) => Promise<void>)[] = []
|
||||||
|
|
||||||
|
register(callback: (data: T) => Promise<void>) {
|
||||||
|
this.callbacks.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(data: T): Promise<void> {
|
||||||
|
await Promise.all(this.callbacks.map(callback => callback(data)))
|
||||||
|
}
|
||||||
|
}
|
3
server/src/core/Identifiable.ts
Normal file
3
server/src/core/Identifiable.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export interface Identifiable {
|
||||||
|
id: string
|
||||||
|
}
|
3
server/src/core/exceptions/LimitReached.ts
Normal file
3
server/src/core/exceptions/LimitReached.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export class LimitReached extends Error {
|
||||||
|
|
||||||
|
}
|
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import {getAdminProjectId, getAdminSucessTagId} from "@app/utils";
|
||||||
|
import {LogsUseCases} from "../logs/LogsUseCases";
|
||||||
|
import {MetricFeature} from "../metrics";
|
||||||
|
import {onMetricCreatedData} from "../metrics/MetricsEvents";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||||
|
import {ProjectContext} from "../projects/ProjectContext";
|
||||||
|
import {onProjectCreatedData} from "../projects/ProjectsEvents";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {onUserCreatedData} from "../users/UsersEvents";
|
||||||
|
|
||||||
|
interface ActivityLoggerDependencies {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
logService: LogsUseCases
|
||||||
|
metricFeature: MetricFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActivityLoggerService {
|
||||||
|
private _userFeature: UserFeature
|
||||||
|
private _projectFeature: ProjectFeature
|
||||||
|
private _logService: LogsUseCases
|
||||||
|
private _metricFeature: MetricFeature
|
||||||
|
private _systemCaller = new Caller(new SystemCaller())
|
||||||
|
|
||||||
|
constructor(dependencies: ActivityLoggerDependencies) {
|
||||||
|
this._userFeature = dependencies.userFeature
|
||||||
|
this._projectFeature = dependencies.projectFeature
|
||||||
|
this._logService = dependencies.logService
|
||||||
|
this._metricFeature = dependencies.metricFeature
|
||||||
|
|
||||||
|
this._metricFeature.events.onMetricCreated.register(data => this.logMetricCreation(data))
|
||||||
|
this._projectFeature.events.onProjectCreated.register(data => this.logProjectCreation(data))
|
||||||
|
this._userFeature.events.onMetricCreated.register(data => this.adminLogUserCreation(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async adminLogUserCreation(data: onUserCreatedData): Promise<void> {
|
||||||
|
const adminProjectId = getAdminProjectId()
|
||||||
|
if (!adminProjectId)
|
||||||
|
return ;
|
||||||
|
const project = await this._projectFeature.projectRepository.findProjectById(adminProjectId)
|
||||||
|
if (!project)
|
||||||
|
return ;
|
||||||
|
const context = new ProjectContext(project, this._systemCaller)
|
||||||
|
const adminSucessTagId = getAdminSucessTagId()
|
||||||
|
await this._logService.addLog(context, {
|
||||||
|
title: `New user: ${data.user.email}`,
|
||||||
|
tags: adminSucessTagId ? [adminSucessTagId] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logProjectCreation(data: onProjectCreatedData): Promise<void> {
|
||||||
|
const context = new ProjectContext(data.project, this._systemCaller)
|
||||||
|
await this._logService.addLog(context, {
|
||||||
|
title: `Welcome to your new project: "${data.project.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logMetricCreation(data: onMetricCreatedData): Promise<void> {
|
||||||
|
const context = new ProjectContext(data.project, this._systemCaller)
|
||||||
|
await this._logService.addLog(context, {
|
||||||
|
title: `Created metric "${data.metric.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
31
server/src/core/features/activityLogger/index.ts
Normal file
31
server/src/core/features/activityLogger/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {LogFeature} from "../logs";
|
||||||
|
import {MetricFeature} from "../metrics";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {ActivityLoggerService} from "./ActivityLogger";
|
||||||
|
|
||||||
|
export type ActivityLoggerFeatureDependencies = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
logFeature: LogFeature
|
||||||
|
metricFeature: MetricFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActivityLoggerFeature extends Feature {
|
||||||
|
|
||||||
|
public name = "ActivityLogger"
|
||||||
|
public service: ActivityLoggerService
|
||||||
|
public dependencies: ActivityLoggerFeatureDependencies
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: ActivityLoggerFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.dependencies = dependencies
|
||||||
|
this.service = new ActivityLoggerService({
|
||||||
|
userFeature: this.dependencies.userFeature,
|
||||||
|
projectFeature: this.dependencies.projectFeature,
|
||||||
|
logService: this.dependencies.logFeature.useCases,
|
||||||
|
metricFeature: this.dependencies.metricFeature
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
71
server/src/core/features/logs/LogsRepository.ts
Normal file
71
server/src/core/features/logs/LogsRepository.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||||
|
import {Project} from "../projects/entities/Project";
|
||||||
|
import {LogDataOnCreation, Log} from "./entities";
|
||||||
|
|
||||||
|
export interface LogDocument {
|
||||||
|
projectId: ObjectId
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
tags?: string[]
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertLogDocumentToEntity(document: WithId<LogDocument>): Log {
|
||||||
|
return new Log({
|
||||||
|
id: document._id.toString(),
|
||||||
|
projectId: document.projectId.toString(),
|
||||||
|
title: document.title,
|
||||||
|
tags: document.tags || [],
|
||||||
|
content: document.content,
|
||||||
|
createdAt: document.createdAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogsRepository {
|
||||||
|
|
||||||
|
private _projectsLogs: Collection<LogDocument>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._projectsLogs = db.collection("projectsLogs")
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(project: Project, data: LogDataOnCreation): Promise<void> {
|
||||||
|
await this._projectsLogs.insertOne({
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
tags: data.tags,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLogs(project: Project): Promise<Log[]> {
|
||||||
|
const results = await this._projectsLogs.find({
|
||||||
|
projectId: new ObjectId(project.id)
|
||||||
|
})
|
||||||
|
.sort({createdAt: -1})
|
||||||
|
.toArray();
|
||||||
|
const resultsWithId = results.map(value => ConvertLogDocumentToEntity(value));
|
||||||
|
return resultsWithId
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLogById(logId: string): Promise<Log | null> {
|
||||||
|
const result = await this._projectsLogs.findOne({
|
||||||
|
_id: new ObjectId(logId),
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return ConvertLogDocumentToEntity(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLog(log: Log): Promise<void> {
|
||||||
|
await this._projectsLogs.deleteOne({
|
||||||
|
_id: new ObjectId(log.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this._projectsLogs.countDocuments();
|
||||||
|
}
|
||||||
|
}
|
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||||
|
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||||
|
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||||
|
import {Log, LogDataOnCreation, Tag, TagOnCreation} from "./entities";
|
||||||
|
import {LogsRepository} from "./LogsRepository";
|
||||||
|
import {TagsRepository} from "./TagsRepository";
|
||||||
|
|
||||||
|
interface LogServiceDependencies {
|
||||||
|
projectService: ProjectsUseCases,
|
||||||
|
logsRepository: LogsRepository,
|
||||||
|
tagsRepository: TagsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogsUseCases {
|
||||||
|
private _projectService: ProjectsUseCases
|
||||||
|
private _logsRepository: LogsRepository
|
||||||
|
private _tagsRepository: TagsRepository
|
||||||
|
|
||||||
|
constructor(dependencies: LogServiceDependencies) {
|
||||||
|
this._projectService = dependencies.projectService
|
||||||
|
this._logsRepository = dependencies.logsRepository
|
||||||
|
this._tagsRepository = dependencies.tagsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLog(context: ProjectContext, log: LogDataOnCreation): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
await this._logsRepository.create(context.project, {
|
||||||
|
title: log.title,
|
||||||
|
content: log.content,
|
||||||
|
tags: log.tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogById(context: ProjectContext, logId: string): Promise<Log> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
const log = await this._logsRepository.findLogById(logId)
|
||||||
|
if (!log) {
|
||||||
|
throw new MissingResource("Log doesn't exist")
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(context: ProjectContext) {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
return await this._logsRepository.findLogs(context.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLog(context: ProjectContext, log: Log) {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
await this._logsRepository.deleteLog(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTag(context: ProjectContext, data: Omit<TagOnCreation, "projectId">): Promise<Tag> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
return this._tagsRepository.create(context.project, {
|
||||||
|
projectId: context.project.id,
|
||||||
|
name: data.name,
|
||||||
|
color: data.color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTag(context: ProjectContext, tag: Tag, data: Omit<TagOnCreation, "projectId">): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
await this._tagsRepository.updateTag(tag, {
|
||||||
|
name: data.name,
|
||||||
|
color: data.color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTags(context: ProjectContext): Promise<Tag[]> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
return await this._tagsRepository.findByProject(context.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTag(context: ProjectContext, id: string): Promise<Tag> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const tag = await this._tagsRepository.findById(context.project, id);
|
||||||
|
if (!tag) {
|
||||||
|
throw new MissingResource("Tag doesn't exist")
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTag(context: ProjectContext, tag: Tag): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
await this._tagsRepository.deleteTag(tag);
|
||||||
|
}
|
||||||
|
}
|
80
server/src/core/features/logs/TagsRepository.ts
Normal file
80
server/src/core/features/logs/TagsRepository.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||||
|
import {Project} from "../projects/entities/Project";
|
||||||
|
import {Tag, TagOnCreation} from "./entities";
|
||||||
|
|
||||||
|
export interface TagDocument {
|
||||||
|
projectId: ObjectId
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTagDocumentToEntity(document: WithId<TagDocument>): Tag {
|
||||||
|
return {
|
||||||
|
id: document._id.toString(),
|
||||||
|
projectId: document.projectId.toString(),
|
||||||
|
name: document.name,
|
||||||
|
color: document.color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TagsRepository {
|
||||||
|
|
||||||
|
private _tags: Collection<TagDocument>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._tags = db.collection("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(project: Project, data: TagOnCreation): Promise<Tag> {
|
||||||
|
const tag = await this._tags.insertOne({
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
name: data.name,
|
||||||
|
color: data.color
|
||||||
|
});
|
||||||
|
return ConvertTagDocumentToEntity({
|
||||||
|
_id: tag.insertedId,
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
name: data.name,
|
||||||
|
color: data.color
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTag(tag: Tag, data: Partial<TagDocument>): Promise<void> {
|
||||||
|
await this._tags.updateOne({_id: new ObjectId(tag.id)}, {
|
||||||
|
$set: {
|
||||||
|
name: data.name,
|
||||||
|
color: data.color
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByProject(project: Project): Promise<Tag[]> {
|
||||||
|
const results = await this._tags.find({
|
||||||
|
projectId: new ObjectId(project.id)
|
||||||
|
})
|
||||||
|
.sort({createdAt: -1})
|
||||||
|
.toArray();
|
||||||
|
const resultsWithId = results.map(value => ConvertTagDocumentToEntity(value));
|
||||||
|
return resultsWithId
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(project: Project, id: string): Promise<Tag | null> {
|
||||||
|
const result = await this._tags.findOne({
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
_id: new ObjectId(id)
|
||||||
|
})
|
||||||
|
if (!result)
|
||||||
|
return null
|
||||||
|
return ConvertTagDocumentToEntity(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTag(tag: Tag): Promise<void> {
|
||||||
|
await this._tags.deleteOne({
|
||||||
|
_id: new ObjectId(tag.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this._tags.countDocuments();
|
||||||
|
}
|
||||||
|
}
|
35
server/src/core/features/logs/entities/Log.ts
Normal file
35
server/src/core/features/logs/entities/Log.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import {Identifiable} from "@app/core/Identifiable"
|
||||||
|
|
||||||
|
export interface LogDataOnCreation {
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogConstructor extends Identifiable {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
tags?: string[]
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Log {
|
||||||
|
|
||||||
|
public readonly id: string
|
||||||
|
public readonly projectId: string
|
||||||
|
public readonly title: string
|
||||||
|
public readonly content?: string
|
||||||
|
public readonly tags: string[]
|
||||||
|
public readonly createdAt: number
|
||||||
|
|
||||||
|
constructor(data: LogConstructor) {
|
||||||
|
this.id = data.id
|
||||||
|
this.projectId = data.projectId
|
||||||
|
this.title = data.title
|
||||||
|
this.content = data.content
|
||||||
|
this.tags = data.tags || []
|
||||||
|
this.createdAt = data.createdAt
|
||||||
|
}
|
||||||
|
}
|
12
server/src/core/features/logs/entities/Tag.ts
Normal file
12
server/src/core/features/logs/entities/Tag.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export interface TagOnCreation {
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
2
server/src/core/features/logs/entities/index.ts
Normal file
2
server/src/core/features/logs/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./Log"
|
||||||
|
export * from "./Tag"
|
32
server/src/core/features/logs/index.ts
Normal file
32
server/src/core/features/logs/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {LogsRepository} from "./LogsRepository";
|
||||||
|
import {LogsUseCases} from "./LogsUseCases";
|
||||||
|
import {TagsRepository} from "./TagsRepository";
|
||||||
|
|
||||||
|
export type LogFeatureDependencies = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogFeature extends Feature {
|
||||||
|
|
||||||
|
name = "Log"
|
||||||
|
logsRepository: LogsRepository
|
||||||
|
tagsRepository: TagsRepository
|
||||||
|
useCases: LogsUseCases
|
||||||
|
dependencies: LogFeatureDependencies
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: LogFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.logsRepository = new LogsRepository(services.db)
|
||||||
|
this.tagsRepository = new TagsRepository(services.db)
|
||||||
|
this.dependencies = dependencies
|
||||||
|
this.useCases = new LogsUseCases({
|
||||||
|
projectService: this.dependencies.projectFeature.service,
|
||||||
|
logsRepository: this.logsRepository,
|
||||||
|
tagsRepository: this.tagsRepository
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||||
|
import {Caller} from "../projects/entities/Caller";
|
||||||
|
import {Project} from "../projects/entities/Project";
|
||||||
|
import {Metric} from ".";
|
||||||
|
|
||||||
|
export type onMetricCreatedData = {
|
||||||
|
project: Project
|
||||||
|
metric: Metric
|
||||||
|
caller: Caller
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricEvents {
|
||||||
|
onMetricCreated: FeatureEvent<onMetricCreatedData> = new FeatureEvent()
|
||||||
|
}
|
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||||
|
import {Project} from "../projects/entities";
|
||||||
|
import {Metric, MetricValue} from "./entities";
|
||||||
|
|
||||||
|
interface MetricDocument {
|
||||||
|
projectId: ObjectId
|
||||||
|
name: string
|
||||||
|
currentValue: undefined | number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricHistoryDocument {
|
||||||
|
metricId: ObjectId
|
||||||
|
date: number // timestamp
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricDocumentToEntity(document: WithId<MetricDocument>, values: WithId<MetricHistoryDocument>[] | null): Metric {
|
||||||
|
return new Metric({
|
||||||
|
id: document._id.toString(),
|
||||||
|
name: document.name,
|
||||||
|
projectId: document.projectId.toString(),
|
||||||
|
currentValue: document.currentValue,
|
||||||
|
history: values ? values.map(MetricHistoryDocumentToEntity) : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricHistoryDocumentToEntity(document: WithId<MetricHistoryDocument>): MetricValue {
|
||||||
|
return {
|
||||||
|
id: document._id.toString(),
|
||||||
|
metricId: document.metricId.toString(),
|
||||||
|
date: document.date,
|
||||||
|
value: document.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricsRepository {
|
||||||
|
|
||||||
|
private _metrics: Collection<MetricDocument>
|
||||||
|
private _metricsHistory: Collection<MetricHistoryDocument>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._metrics = db.collection("projectsMetrics")
|
||||||
|
this._metricsHistory = db.collection("projectsMetricsHistory")
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMetric(project: Project, name: string): Promise<Metric> {
|
||||||
|
const metric = await this._metrics.insertOne({
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
name: name,
|
||||||
|
currentValue: undefined
|
||||||
|
});
|
||||||
|
return MetricDocumentToEntity({
|
||||||
|
_id: metric.insertedId,
|
||||||
|
projectId: new ObjectId(project.id),
|
||||||
|
name,
|
||||||
|
currentValue: undefined
|
||||||
|
}, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMetric(metric: Metric, name: string): Promise<void> {
|
||||||
|
await this._metrics.updateOne({_id: new ObjectId(metric.id)}, {
|
||||||
|
$set: {
|
||||||
|
name: name,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async AddValueToHistory(metric: Metric, value: Omit<MetricValue, "id" | "metricId">): Promise<void> {
|
||||||
|
const metricQuery = {projectId: new ObjectId(metric.projectId), name: metric.name}
|
||||||
|
const newValueDocument = {...value, metricId: new ObjectId(metric.id)}
|
||||||
|
await this._metricsHistory.insertOne(newValueDocument);
|
||||||
|
await this._metrics.updateOne(metricQuery, {$set: {currentValue: value.value}})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteHistoryRecord(metric: Metric, recordId: string): Promise<void> {
|
||||||
|
const deleteFilter = {metricId: new ObjectId(metric.id), _id: new ObjectId(recordId)}
|
||||||
|
await this._metricsHistory.deleteOne(deleteFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(project: Project, fetchValues = false): Promise<Metric[]> {
|
||||||
|
const metric = await this._metrics.find({projectId: new ObjectId(project.id)}).toArray()
|
||||||
|
const metricIds = metric.map(metric => metric._id);
|
||||||
|
const allMetricValues = await this._metricsHistory.find({metricId: {$in: metricIds}}).sort({date: 1}).toArray()
|
||||||
|
return metric.map(thisMetric => {
|
||||||
|
if (!fetchValues) {
|
||||||
|
return MetricDocumentToEntity(thisMetric, null);
|
||||||
|
}
|
||||||
|
const values = allMetricValues.filter(value => value.metricId.equals(thisMetric._id))
|
||||||
|
return MetricDocumentToEntity(thisMetric, values);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(project: Project, name: string, fetchValues = false): Promise<Metric | null> {
|
||||||
|
|
||||||
|
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name})
|
||||||
|
if (!metric) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!fetchValues) {
|
||||||
|
return MetricDocumentToEntity(metric, null);
|
||||||
|
}
|
||||||
|
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||||
|
return MetricDocumentToEntity(metric, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMetricById(project: Project, id: string, fetchValues = false): Promise<Metric | null> {
|
||||||
|
|
||||||
|
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id),_id: new ObjectId(id)})
|
||||||
|
if (!metric) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!fetchValues) {
|
||||||
|
return MetricDocumentToEntity(metric, null);
|
||||||
|
}
|
||||||
|
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||||
|
return MetricDocumentToEntity(metric, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async MetricExists(project: Project, name: string): Promise<boolean> {
|
||||||
|
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name});
|
||||||
|
return metric !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMetric(metric: Metric): Promise<void> {
|
||||||
|
const metricId = new ObjectId(metric.id);
|
||||||
|
await this._metricsHistory.deleteMany({metricId: metricId})
|
||||||
|
await this._metrics.deleteOne({_id: metricId})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistoryLengthOfMetric(metric: Metric): Promise<number> {
|
||||||
|
return this._metricsHistory.countDocuments({metricId: new ObjectId(metric.id)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this._metrics.countDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async countHistory(): Promise<number> {
|
||||||
|
return this._metricsHistory.countDocuments();
|
||||||
|
}
|
||||||
|
}
|
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||||
|
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||||
|
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||||
|
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||||
|
import {getMetricsHistoryLimit} from "../users/utils";
|
||||||
|
import {Metric, MetricCreationProperties} from "./entities";
|
||||||
|
import {MetricEvents} from "./MetricsEvents";
|
||||||
|
import {MetricsRepository} from "./MetricsRepository";
|
||||||
|
|
||||||
|
interface MetricServiceDependencies {
|
||||||
|
projectService: ProjectsUseCases,
|
||||||
|
metricRepository: MetricsRepository,
|
||||||
|
metricEvents: MetricEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricsUseCases {
|
||||||
|
private _projectService: ProjectsUseCases
|
||||||
|
private _metricRepository: MetricsRepository
|
||||||
|
private _metricEvents: MetricEvents
|
||||||
|
|
||||||
|
constructor(dependencies: MetricServiceDependencies) {
|
||||||
|
this._projectService = dependencies.projectService
|
||||||
|
this._metricRepository = dependencies.metricRepository
|
||||||
|
this._metricEvents = dependencies.metricEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMetric(context: ProjectContext, metricInfo: MetricCreationProperties): Promise<Metric> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const {project, caller} = context;
|
||||||
|
|
||||||
|
const metric = await this._metricRepository.createMetric(project, metricInfo.name)
|
||||||
|
await this._metricEvents.onMetricCreated.emit({project, metric: metric, caller});
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMetric(context: ProjectContext, metric: Metric, metricInfo: MetricCreationProperties): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
await this._metricRepository.updateMetric(metric, metricInfo.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMetrics(context: ProjectContext, fetchValues?: boolean): Promise<Metric[]> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
const metric = await this._metricRepository.findAll(context.project, fetchValues);
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetricById(context: ProjectContext, metricId: string, fetchValues?: boolean): Promise<Metric> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
const metric = await this._metricRepository.findMetricById(context.project, metricId, fetchValues);
|
||||||
|
if (!metric) {
|
||||||
|
throw new MissingResource("Metric doesn't exist")
|
||||||
|
}
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMetric(context: ProjectContext, metric: Metric): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
await this._metricRepository.deleteMetric(metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMetricHistoryRecord(context: ProjectContext, metric: Metric, recordId: string): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
await this._metricRepository.deleteHistoryRecord(metric, recordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMetricValue(context: ProjectContext, metric: Metric, value: number, date?: number) {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
|
||||||
|
const currentHistoryLength = await this._metricRepository.getHistoryLengthOfMetric(metric)
|
||||||
|
|
||||||
|
if (currentHistoryLength >= getMetricsHistoryLimit()) {
|
||||||
|
throw new LimitReached("History limit reached (experimental feature)")
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._metricRepository.AddValueToHistory(metric, {
|
||||||
|
date: date || Date.now(),
|
||||||
|
value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import {Identifiable} from "@app/core/Identifiable"
|
||||||
|
|
||||||
|
|
||||||
|
export interface MetricCreationProperties {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricConstructor extends Identifiable {
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
history: MetricValue[] | null
|
||||||
|
currentValue: undefined | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricValue extends Identifiable {
|
||||||
|
metricId: string
|
||||||
|
date: number // timestamp
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Metric implements MetricConstructor {
|
||||||
|
|
||||||
|
public readonly id: string
|
||||||
|
public readonly projectId: string
|
||||||
|
public readonly name: string
|
||||||
|
public readonly history: MetricValue[] | null
|
||||||
|
public readonly currentValue: undefined | number
|
||||||
|
|
||||||
|
constructor(data: MetricConstructor) {
|
||||||
|
this.id = data.id
|
||||||
|
this.projectId = data.projectId
|
||||||
|
this.name = data.name
|
||||||
|
this.history = data.history
|
||||||
|
this.currentValue = data.currentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValues(): boolean {
|
||||||
|
return this.history !== null
|
||||||
|
}
|
||||||
|
}
|
1
server/src/core/features/metrics/entities/index.ts
Normal file
1
server/src/core/features/metrics/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./Metric"
|
35
server/src/core/features/metrics/index.ts
Normal file
35
server/src/core/features/metrics/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {MetricEvents} from "./MetricsEvents";
|
||||||
|
import {MetricsRepository} from "./MetricsRepository";
|
||||||
|
import {MetricsUseCases} from "./MetricsUseCases";
|
||||||
|
|
||||||
|
export * from "./MetricsUseCases"
|
||||||
|
export * from "./entities"
|
||||||
|
|
||||||
|
export type MetricFeatureDependencies = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetricFeature extends Feature {
|
||||||
|
|
||||||
|
name = "Metric"
|
||||||
|
events: MetricEvents = new MetricEvents()
|
||||||
|
dependencies: MetricFeatureDependencies
|
||||||
|
|
||||||
|
repository: MetricsRepository
|
||||||
|
useCases: MetricsUseCases
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: MetricFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.repository = new MetricsRepository(services.db)
|
||||||
|
this.dependencies = dependencies
|
||||||
|
this.useCases = new MetricsUseCases({
|
||||||
|
projectService: this.dependencies.projectFeature.service,
|
||||||
|
metricRepository: this.repository,
|
||||||
|
metricEvents: this.events
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import {Collection, Db, ObjectId} from "mongodb";
|
||||||
|
import {ApiKey} from "./entities/ApiKey";
|
||||||
|
import {ApiKeyData} from "./entities/ApiKeyData";
|
||||||
|
import {Project} from "./entities/Project";
|
||||||
|
|
||||||
|
export class ApiKeyRepository {
|
||||||
|
|
||||||
|
private _repository: Collection<Omit<ApiKeyData, "id">>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._repository = db.collection("projectsApiKeys")
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Omit<ApiKeyData, "id">): Promise<void> {
|
||||||
|
await this._repository.insertOne(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(apiKey: ApiKey): Promise<void> {
|
||||||
|
await this._repository.deleteOne({_id: new ObjectId(apiKey.id)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByKey(key: string): Promise<ApiKey | null> {
|
||||||
|
const result = await this._repository.findOne({key});
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ApiKey({...result, id: result._id.toString()})
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<ApiKey | null> {
|
||||||
|
const result = await this._repository.findOne({_id: new ObjectId(id)});
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ApiKey({...result, id: result._id.toString()})
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByProject(project: Project): Promise<ApiKey[]> {
|
||||||
|
const results = await this._repository.find({projectId: project.id}).toArray();
|
||||||
|
const resultsWithId = results.map(value => new ApiKey(({...value, id: value._id.toString()})));
|
||||||
|
return resultsWithId;
|
||||||
|
}
|
||||||
|
}
|
33
server/src/core/features/projects/ProjectContext.ts
Normal file
33
server/src/core/features/projects/ProjectContext.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import {Project, Caller} from "./entities";
|
||||||
|
import {MissingAuthorization} from "./exceptions/MissingAuthorization";
|
||||||
|
|
||||||
|
export enum ProjectAuthorization {
|
||||||
|
Read = 'Read',
|
||||||
|
Write = 'Write'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectContext {
|
||||||
|
|
||||||
|
project: Project
|
||||||
|
caller: Caller
|
||||||
|
|
||||||
|
constructor(project: Project, caller: Caller) {
|
||||||
|
this.project = project
|
||||||
|
this.caller = caller
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceAuthorizations(authorizations: string[]): void {
|
||||||
|
if (this.caller.isSystem())
|
||||||
|
return
|
||||||
|
if (this.caller.isUser() && this.caller.asUser().id === this.project.ownerId)
|
||||||
|
return
|
||||||
|
if (this.caller.isApiKey() && this.caller.asApiKey().projectId === this.project.id) {
|
||||||
|
const hasAuthorizations = authorizations.every(authorization =>
|
||||||
|
this.caller.asApiKey().hasAuthorization(authorization)
|
||||||
|
)
|
||||||
|
if (hasAuthorizations)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new MissingAuthorization("Missing authorization");
|
||||||
|
}
|
||||||
|
}
|
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||||
|
import {Project, Caller} from "../projects/entities";
|
||||||
|
|
||||||
|
export type onProjectCreatedData = {
|
||||||
|
project: Project
|
||||||
|
caller: Caller
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectEvents {
|
||||||
|
onProjectCreated: FeatureEvent<onProjectCreatedData> = new FeatureEvent()
|
||||||
|
}
|
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import {Project, ProjectData, Tag} from "./entities";
|
||||||
|
import {User} from "@app/core/features/users/entities/User";
|
||||||
|
import {Collection, Db, ObjectId} from "mongodb";
|
||||||
|
|
||||||
|
export class ProjectsRepository {
|
||||||
|
|
||||||
|
private _projects: Collection<Omit<ProjectData, "id">>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._projects = db.collection("projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(data: Omit<ProjectData, "id">): Promise<Project> {
|
||||||
|
const createProject = await this._projects.insertOne(data);
|
||||||
|
return this._format({...data, id: createProject.insertedId.toString()})
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveProject(project: Project): Promise<void> {
|
||||||
|
await this._projects.updateOne(
|
||||||
|
{_id: new ObjectId(project.id)},
|
||||||
|
{$set: {...project.getState(), updatedAt: Date.now()}}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTag(project: Project, tag: Tag) : Promise<void> {
|
||||||
|
await this._projects.updateOne({_id: new ObjectId(project.id)}, {$push: {tags: tag}});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findProjectsOfUser(user: User): Promise<Project[]> {
|
||||||
|
const results = await this._projects.find({ownerId: user.id}).toArray();
|
||||||
|
const resultsWithId = results.map(value => this._format({...value, id: value._id.toString()}));
|
||||||
|
return resultsWithId
|
||||||
|
}
|
||||||
|
|
||||||
|
async findProjectById(id: string): Promise<Project | null> {
|
||||||
|
const result = await this._projects.findOne({_id: new ObjectId(id)});
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this._format({...result, id: result._id.toString()})
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this._projects.countDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*async createLog(data: Omit<LogData, "id">): Promise<void> {
|
||||||
|
await this._projectsLogs.insertOne(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLogsOfProject(project: Project): Promise<LogData[]> {
|
||||||
|
const results = await this._projectsLogs.find({projectId: project.id}).toArray();
|
||||||
|
const resultsWithId = results.map(value => ({...value, id: value._id.toString()}));
|
||||||
|
return resultsWithId
|
||||||
|
}*/
|
||||||
|
|
||||||
|
private _format(data: Partial<ProjectData>): Project {
|
||||||
|
if (!data.id) {
|
||||||
|
throw Error("Id is undefined")
|
||||||
|
}
|
||||||
|
if (!data.ownerId) {
|
||||||
|
throw Error("ownerId is undefined")
|
||||||
|
}
|
||||||
|
return new Project ({
|
||||||
|
id: data.id,
|
||||||
|
ownerId: data.ownerId,
|
||||||
|
name: data.name || "Missing name",
|
||||||
|
tags: data.tags || [],
|
||||||
|
createdAt: data.createdAt || 0,
|
||||||
|
updatedAt: data.updatedAt || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||||
|
import {User} from "@app/core/features/users/entities/User";
|
||||||
|
import {randomBytes} from "crypto";
|
||||||
|
import {getAccountProjectsLimit} from "../users/utils";
|
||||||
|
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||||
|
import {ApiKey, Caller, Project} from "./entities";
|
||||||
|
import {MissingResource} from "./exceptions/MissingResource";
|
||||||
|
import {ProjectAuthorization, ProjectContext} from "./ProjectContext";
|
||||||
|
import {ProjectEvents} from "./ProjectsEvents";
|
||||||
|
import {ProjectsRepository} from "./ProjectsRepository";
|
||||||
|
|
||||||
|
interface ProjectServiceDependencies {
|
||||||
|
projectRepository: ProjectsRepository,
|
||||||
|
projectEvents: ProjectEvents
|
||||||
|
apiKeyRepository: ApiKeyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectsUseCases {
|
||||||
|
private _projectRepository: ProjectsRepository
|
||||||
|
private _projectEvents: ProjectEvents
|
||||||
|
private _apiKeyRepository: ApiKeyRepository
|
||||||
|
|
||||||
|
constructor(dependencies: ProjectServiceDependencies) {
|
||||||
|
this._projectRepository = dependencies.projectRepository
|
||||||
|
this._projectEvents = dependencies.projectEvents
|
||||||
|
this._apiKeyRepository = dependencies.apiKeyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(owner: User, name: string): Promise<Project> {
|
||||||
|
const userProjects = await this._projectRepository.findProjectsOfUser(owner);
|
||||||
|
if (userProjects.length >= getAccountProjectsLimit()) {
|
||||||
|
throw new LimitReached(`Maximum account limit (${getAccountProjectsLimit()}) reached`)
|
||||||
|
}
|
||||||
|
const project = await this._projectRepository.createProject({
|
||||||
|
name,
|
||||||
|
ownerId: owner.id,
|
||||||
|
tags: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await this._projectEvents.onProjectCreated.emit({project, caller: new Caller(owner)})
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectsOfUser(user: User): Promise<Project[]> {
|
||||||
|
const projects = await this._projectRepository.findProjectsOfUser(user);
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectById(caller: Caller, id: string): Promise<Project | null> {
|
||||||
|
const project = await this._projectRepository.findProjectById(id);
|
||||||
|
if (!project) {
|
||||||
|
throw new MissingResource("Project not found")
|
||||||
|
}
|
||||||
|
const context = new ProjectContext(project, caller)
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTagToProject(context: ProjectContext, {name, color}: {name: string, color: string}): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const {project} = context;
|
||||||
|
await this._projectRepository.addTag(project, {id: project.tags.length + 1, name, color});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTagOfProject(context: ProjectContext, id: number, {name, color}: {name: string, color: string}): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const {project} = context;
|
||||||
|
project.changeTag(id, name, color);
|
||||||
|
await this._projectRepository.saveProject(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateApiKey(context: ProjectContext, name: string): Promise<string> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const apiKey = randomBytes(32).toString("base64").replace(/=/gi, "");
|
||||||
|
await this._apiKeyRepository.create({
|
||||||
|
key: apiKey,
|
||||||
|
projectId: context.project.id,
|
||||||
|
name,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
return apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApiKeysOfProject(context: ProjectContext): Promise<ApiKey[]> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
return this._apiKeyRepository.findAllByProject(context.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteApiKeys(context: ProjectContext, apiKey: ApiKey): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
return this._apiKeyRepository.delete(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApiKey(context: ProjectContext, apiKey: string): Promise<ApiKey | null> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
return this._apiKeyRepository.findByKey(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApiKeyById(context: ProjectContext, keyId: string): Promise<ApiKey> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
const apiKey = await this._apiKeyRepository.findById(keyId);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new MissingResource("Api key not found")
|
||||||
|
}
|
||||||
|
return apiKey
|
||||||
|
}
|
||||||
|
}
|
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import {ProjectAuthorization} from "../ProjectContext";
|
||||||
|
import {ApiKeyData} from "./ApiKeyData";
|
||||||
|
|
||||||
|
export interface ApiKey extends Readonly<ApiKeyData> {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiKey {
|
||||||
|
|
||||||
|
authorizations: string[]
|
||||||
|
|
||||||
|
constructor(data: ApiKeyData) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
this.authorizations = [ProjectAuthorization.Read, ProjectAuthorization.Write]
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAuthorization(authorization: string): boolean {
|
||||||
|
return this.authorizations.includes(authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.key
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): ApiKeyData {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
key: this.key,
|
||||||
|
name: this.name,
|
||||||
|
projectId: this.projectId,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export interface ApiKeyData {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
73
server/src/core/features/projects/entities/Caller.ts
Normal file
73
server/src/core/features/projects/entities/Caller.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import {User} from "@app/core/features/users/entities";
|
||||||
|
import {ApiKey} from "./ApiKey";
|
||||||
|
import {Project} from "./Project";
|
||||||
|
|
||||||
|
export class SystemCaller {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminUser {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Caller {
|
||||||
|
|
||||||
|
private _user?: User
|
||||||
|
private _apiKey?: ApiKey
|
||||||
|
private _isSystem = false
|
||||||
|
private _isAdmin = false
|
||||||
|
|
||||||
|
constructor(caller: User | ApiKey | SystemCaller | AdminUser) {
|
||||||
|
if (caller instanceof User) {
|
||||||
|
this._user = caller
|
||||||
|
} else if (caller instanceof ApiKey) {
|
||||||
|
this._apiKey = caller
|
||||||
|
} else if (caller instanceof SystemCaller) {
|
||||||
|
this._isSystem = true
|
||||||
|
} else if (caller instanceof AdminUser) {
|
||||||
|
this._isAdmin = true
|
||||||
|
} else {
|
||||||
|
throw new Error("Wrong type for Caller constructor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanReadProject(project: Project): boolean {
|
||||||
|
if (this?._user && this._user.id === project.ownerId)
|
||||||
|
return true
|
||||||
|
if (this?._apiKey && this._apiKey.projectId === project.id)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
CanWriteProject(project: Project): boolean {
|
||||||
|
return this.CanReadProject(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
isApiKey(): boolean {
|
||||||
|
return !!this._apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
isUser(): boolean {
|
||||||
|
return !!this._user
|
||||||
|
}
|
||||||
|
|
||||||
|
isSystem(): boolean {
|
||||||
|
return this._isSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin(): boolean {
|
||||||
|
return this._isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
asApiKey(): ApiKey {
|
||||||
|
if (!this.isApiKey())
|
||||||
|
throw new Error("Trying to get caller User as ApiKey")
|
||||||
|
return this._apiKey as ApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
asUser(): User {
|
||||||
|
if (!this.isUser())
|
||||||
|
throw new Error("Trying to get caller ApiKey as User")
|
||||||
|
return this._user as User
|
||||||
|
}
|
||||||
|
}
|
45
server/src/core/features/projects/entities/Project.ts
Normal file
45
server/src/core/features/projects/entities/Project.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import {Tag} from "./Tag";
|
||||||
|
import {ProjectData} from "./ProjectData";
|
||||||
|
|
||||||
|
export class Project implements ProjectData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ownerId: string
|
||||||
|
tags: Tag[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
|
||||||
|
constructor(data: ProjectData) {
|
||||||
|
this.id = data.id
|
||||||
|
this.name = data.name
|
||||||
|
this.ownerId = data.ownerId
|
||||||
|
this.tags = data.tags
|
||||||
|
this.createdAt = data.createdAt
|
||||||
|
this.updatedAt = data.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): ProjectData {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
ownerId: this.ownerId,
|
||||||
|
name: this.name,
|
||||||
|
tags: this.tags,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changeTag(id: number, name: string, color: string): Tag {
|
||||||
|
const tag = this.tags.find(tag => tag.id === id)
|
||||||
|
if (!tag) {
|
||||||
|
throw new Error(`Tag '${id}' doesn't exist`);
|
||||||
|
}
|
||||||
|
tag.name = name;
|
||||||
|
tag.color = color;
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTag(name: string): boolean {
|
||||||
|
return this.tags.find(tag => tag.name === name) !== undefined
|
||||||
|
}
|
||||||
|
}
|
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import {Tag} from "./Tag"
|
||||||
|
|
||||||
|
export interface ProjectData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ownerId: string
|
||||||
|
tags: Tag[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
5
server/src/core/features/projects/entities/Tag.ts
Normal file
5
server/src/core/features/projects/entities/Tag.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export interface Tag {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
6
server/src/core/features/projects/entities/index.ts
Normal file
6
server/src/core/features/projects/entities/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./ApiKey"
|
||||||
|
export * from "./ApiKeyData"
|
||||||
|
export * from "./Caller"
|
||||||
|
export * from "./Project"
|
||||||
|
export * from "./ProjectData"
|
||||||
|
export * from "./Tag"
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class MissingAuthorization extends Error {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class MissingResource extends Error {
|
||||||
|
|
||||||
|
}
|
32
server/src/core/features/projects/index.ts
Normal file
32
server/src/core/features/projects/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||||
|
import {ProjectEvents} from "./ProjectsEvents";
|
||||||
|
import {ProjectsRepository} from "./ProjectsRepository";
|
||||||
|
import {ProjectsUseCases} from "./ProjectsUseCases";
|
||||||
|
|
||||||
|
export type ProjectFeatureDependencies = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectFeature extends Feature {
|
||||||
|
|
||||||
|
name = "Project"
|
||||||
|
projectRepository: ProjectsRepository
|
||||||
|
apiKeyRepository: ApiKeyRepository
|
||||||
|
service: ProjectsUseCases
|
||||||
|
dependencies: ProjectFeatureDependencies
|
||||||
|
events: ProjectEvents = new ProjectEvents()
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: ProjectFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.projectRepository = new ProjectsRepository(services.db)
|
||||||
|
this.apiKeyRepository = new ApiKeyRepository(services.db)
|
||||||
|
this.service = new ProjectsUseCases({
|
||||||
|
projectRepository: this.projectRepository,
|
||||||
|
projectEvents: this.events,
|
||||||
|
apiKeyRepository: this.apiKeyRepository
|
||||||
|
})
|
||||||
|
this.dependencies = dependencies
|
||||||
|
}
|
||||||
|
}
|
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {LogFeature} from "../logs";
|
||||||
|
import {MetricFeature} from "../metrics";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {AuthenticationFailed} from "../users/exceptions/AuthenticationFailed";
|
||||||
|
|
||||||
|
interface ServerAdminDependencies {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
logFeature: LogFeature,
|
||||||
|
metricFeature: MetricFeature,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAdminUseCases {
|
||||||
|
private _userFeature: UserFeature
|
||||||
|
private _projectFeature: ProjectFeature
|
||||||
|
private _logFeature: LogFeature
|
||||||
|
private _metricFeature: MetricFeature
|
||||||
|
private _systemCaller = new Caller(new SystemCaller())
|
||||||
|
|
||||||
|
constructor(dependencies: ServerAdminDependencies) {
|
||||||
|
this._userFeature = dependencies.userFeature
|
||||||
|
this._projectFeature = dependencies.projectFeature
|
||||||
|
this._logFeature = dependencies.logFeature
|
||||||
|
this._metricFeature = dependencies.metricFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGlobalStats(caller: Caller): Promise<GlobalStats> {
|
||||||
|
if (!caller.isAdmin()) {
|
||||||
|
throw new AuthenticationFailed("User needs to be Admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await this._userFeature.repository.count();
|
||||||
|
const projectCount = await this._projectFeature.projectRepository.count();
|
||||||
|
const logCount = await this._logFeature.logsRepository.count();
|
||||||
|
const metricCount = await this._metricFeature.repository.count();
|
||||||
|
const metricHistoryEntryCount = await this._metricFeature.repository.countHistory();
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCount,
|
||||||
|
projectCount,
|
||||||
|
logCount,
|
||||||
|
metricCount,
|
||||||
|
metricHistoryEntryCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalStats = {
|
||||||
|
userCount: number
|
||||||
|
projectCount: number
|
||||||
|
logCount: number
|
||||||
|
metricCount: number
|
||||||
|
metricHistoryEntryCount: number
|
||||||
|
}
|
31
server/src/core/features/serverAdmin/index.ts
Normal file
31
server/src/core/features/serverAdmin/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {LogFeature} from "../logs";
|
||||||
|
import {MetricFeature} from "../metrics";
|
||||||
|
import {ProjectFeature} from "../projects";
|
||||||
|
import {UserFeature} from "../users";
|
||||||
|
import {ServerAdminUseCases} from "./ServerAdminUseCases";
|
||||||
|
|
||||||
|
export type ServerAdminFeatureDependencies = {
|
||||||
|
userFeature: UserFeature
|
||||||
|
projectFeature: ProjectFeature
|
||||||
|
logFeature: LogFeature
|
||||||
|
metricFeature: MetricFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAdminFeature extends Feature {
|
||||||
|
|
||||||
|
public name = "ActivityLogger"
|
||||||
|
public useCases: ServerAdminUseCases
|
||||||
|
public dependencies: ServerAdminFeatureDependencies
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: ServerAdminFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.dependencies = dependencies
|
||||||
|
this.useCases = new ServerAdminUseCases({
|
||||||
|
userFeature: this.dependencies.userFeature,
|
||||||
|
projectFeature: this.dependencies.projectFeature,
|
||||||
|
logFeature: this.dependencies.logFeature,
|
||||||
|
metricFeature: this.dependencies.metricFeature
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
12
server/src/core/features/users/UsersEvents.ts
Normal file
12
server/src/core/features/users/UsersEvents.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||||
|
import {Caller} from "../projects/entities";
|
||||||
|
import {User} from "./entities";
|
||||||
|
|
||||||
|
export type onUserCreatedData = {
|
||||||
|
user: User
|
||||||
|
caller: Caller
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserEvents {
|
||||||
|
onMetricCreated: FeatureEvent<onUserCreatedData> = new FeatureEvent()
|
||||||
|
}
|
46
server/src/core/features/users/UsersRepository.ts
Normal file
46
server/src/core/features/users/UsersRepository.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import {UserData} from "./entities/UserData";
|
||||||
|
import {Collection, Db, ObjectId} from "mongodb";
|
||||||
|
import { User } from "./entities";
|
||||||
|
|
||||||
|
export class UsersRepository {
|
||||||
|
|
||||||
|
private _users: Collection<Omit<UserData, "id">>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._users = db.collection("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: Omit<UserData, "id">): Promise<void> {
|
||||||
|
await this._users.insertOne(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserById(id: string): Promise<UserData | null> {
|
||||||
|
const userData = await this._users.findOne({_id: new ObjectId(id)});
|
||||||
|
if (!userData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {...userData, id: userData._id.toString()};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByEmail(email: string): Promise<UserData | null> {
|
||||||
|
const userData = await this._users.findOne({email});
|
||||||
|
if (!userData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {...userData, id: userData._id.toString()};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<UserData[]> {
|
||||||
|
const userData = await this._users.find().toArray();
|
||||||
|
return userData.map(data => ({...data, id: data._id.toString()}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this._users.countDocuments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(user: UserData, data: Partial<Omit<UserData, "id">>): Promise<boolean> {
|
||||||
|
const res = await this._users.updateOne({_id: new ObjectId(user.id)}, {$set: data})
|
||||||
|
return res.matchedCount > 0
|
||||||
|
}
|
||||||
|
}
|
109
server/src/core/features/users/UsersUseCases.ts
Normal file
109
server/src/core/features/users/UsersUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import {User} from "./entities/User";
|
||||||
|
import {UsersRepository} from "./UsersRepository";
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import {AuthenticationFailed} from "./exceptions/AuthenticationFailed";
|
||||||
|
import {getAccountCreationLimit, isRegistrationEnabled} from "./utils";
|
||||||
|
import {SignupFailed, SignupFailedCode} from "./exceptions/SignupFailed";
|
||||||
|
import {UserEvents} from "./UsersEvents";
|
||||||
|
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||||
|
|
||||||
|
interface UsersUseCasesDependencies {
|
||||||
|
repository: UsersRepository,
|
||||||
|
events: UserEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsersUseCases {
|
||||||
|
private _repository: UsersRepository
|
||||||
|
private _events: UserEvents
|
||||||
|
|
||||||
|
constructor(dependencies: UsersUseCasesDependencies) {
|
||||||
|
this._repository = dependencies.repository
|
||||||
|
this._events = dependencies.events
|
||||||
|
}
|
||||||
|
|
||||||
|
// [TODO] This function doesn't really belong here, make a new feature (ex: reporter)
|
||||||
|
async getGlobalInfo(adminToken: string) {
|
||||||
|
if (adminToken !== process.env.API_ADMIN_AUTHORIZATION) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: (await this._repository.findAll()).map(user => ({
|
||||||
|
...user,
|
||||||
|
createdAt: new Date(user.createdAt),
|
||||||
|
password: undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminChangeUserPassword(caller: Caller, email: string, newPassword: string) {
|
||||||
|
if (!caller.isAdmin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this._repository.findUserByEmail(email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new SignupFailed("Target user not found", SignupFailedCode.Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._repository.updateUser(user, {password: await argon2.hash(newPassword)})
|
||||||
|
}
|
||||||
|
|
||||||
|
async signupUser(email: string, password: string, adminToken?: string) {
|
||||||
|
if (!isRegistrationEnabled(adminToken)) {
|
||||||
|
throw new SignupFailed("User signup not enabled", SignupFailedCode.NotEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this._repository.count() >= getAccountCreationLimit()) {
|
||||||
|
throw new SignupFailed(`Maximum account limit (${getAccountCreationLimit()}) reached`, SignupFailedCode.LimitReached)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this._repository.findUserByEmail(email);
|
||||||
|
if (user) {
|
||||||
|
throw new SignupFailed("Email is already taken", SignupFailedCode.EmailAlreadyInUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(password);
|
||||||
|
await this._repository.createUser({
|
||||||
|
email,
|
||||||
|
password: passwordHash,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdUser = await this._repository.findUserByEmail(email)
|
||||||
|
|
||||||
|
if (!createdUser) {
|
||||||
|
throw new SignupFailed("Error in user creation", SignupFailedCode.Unknown)
|
||||||
|
}
|
||||||
|
const caller = new Caller(new SystemCaller())
|
||||||
|
await this._events.onMetricCreated.emit({user: new User(createdUser), caller})
|
||||||
|
|
||||||
|
/*if (!process.env.TEST) {*/
|
||||||
|
/*await mailService.SendMail(*/
|
||||||
|
/*`Confirm your email on Cronarium : https://my.awary.com/signup-email-confirmation?email=${email}&emailConfirmationToken=${createdUser?.GetDocument().emailConfirmationToken}`,*/
|
||||||
|
/*[email]);*/
|
||||||
|
/*}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
async logUser(email: string, password: string): Promise<User> {
|
||||||
|
const userData = await this._repository.findUserByEmail(email);
|
||||||
|
if (!userData) {
|
||||||
|
throw new AuthenticationFailed("Wrong email or password")
|
||||||
|
}
|
||||||
|
const user = new User(userData);
|
||||||
|
if (!await user.verifyPassword(password)) {
|
||||||
|
throw new AuthenticationFailed("Wrong email or password")
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
const userData = await this._repository.findUserByEmail(email);
|
||||||
|
if (!userData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new User(userData);
|
||||||
|
}
|
||||||
|
}
|
26
server/src/core/features/users/entities/User.ts
Normal file
26
server/src/core/features/users/entities/User.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import {UserData} from "./UserData";
|
||||||
|
import argon2 from "argon2"
|
||||||
|
|
||||||
|
export interface User extends Readonly<UserData> {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
constructor(data: UserData) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPassword(password: string): Promise<boolean> {
|
||||||
|
return this.password ? argon2.verify(this.password, password) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): UserData {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
email: this.email,
|
||||||
|
password: this.password,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
server/src/core/features/users/entities/UserData.ts
Normal file
7
server/src/core/features/users/entities/UserData.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export interface UserData {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
2
server/src/core/features/users/entities/index.ts
Normal file
2
server/src/core/features/users/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./User"
|
||||||
|
export * from "./UserData"
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class AuthenticationFailed extends Error {
|
||||||
|
|
||||||
|
}
|
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export enum SignupFailedCode {
|
||||||
|
NotEnabled,
|
||||||
|
EmailAlreadyInUse,
|
||||||
|
LimitReached,
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignupFailed extends Error {
|
||||||
|
constructor(message: string, public errorCode: SignupFailedCode) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
19
server/src/core/features/users/index.ts
Normal file
19
server/src/core/features/users/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {UserEvents} from "./UsersEvents";
|
||||||
|
import {UsersRepository} from "./UsersRepository";
|
||||||
|
import {UsersUseCases} from "./UsersUseCases";
|
||||||
|
|
||||||
|
export class UserFeature extends Feature {
|
||||||
|
|
||||||
|
name = "User"
|
||||||
|
repository: UsersRepository
|
||||||
|
useCases: UsersUseCases
|
||||||
|
events: UserEvents
|
||||||
|
|
||||||
|
constructor(services: AppServices) {
|
||||||
|
super(services);
|
||||||
|
this.repository = new UsersRepository(services.db)
|
||||||
|
this.events = new UserEvents()
|
||||||
|
this.useCases = new UsersUseCases({repository: this.repository, events: this.events})
|
||||||
|
}
|
||||||
|
}
|
22
server/src/core/features/users/utils.ts
Normal file
22
server/src/core/features/users/utils.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export function isRegistrationEnabled(adminToken?: string): boolean {
|
||||||
|
if (adminToken && adminToken === process.env.API_ADMIN_AUTHORIZATION) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return process.env.ENABLE_USER_REGISTRATION === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountCreationLimit(): number {
|
||||||
|
return process.env.MAX_ACCOUNT ? parseInt(process.env.MAX_ACCOUNT) : 9999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountProjectsLimit(): number {
|
||||||
|
return process.env.MAX_PROJECT_PER_ACCOUNT ? parseInt(process.env.MAX_PROJECT_PER_ACCOUNT) : 9999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetricsUpdateLimit(): number {
|
||||||
|
return process.env.METRICS_MAX_UPDATE_PER_MINUTE ? parseInt(process.env.METRICS_MAX_UPDATE_PER_MINUTE) : 9999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetricsHistoryLimit(): number {
|
||||||
|
return process.env.METRICS_HISTORY_LENGTH ? parseInt(process.env.METRICS_HISTORY_LENGTH) : 9999999;
|
||||||
|
}
|
81
server/src/core/features/views/ViewsRepository.ts
Normal file
81
server/src/core/features/views/ViewsRepository.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||||
|
import {Project} from "../projects/entities";
|
||||||
|
import {View, ViewProvider} from "./entities";
|
||||||
|
|
||||||
|
interface ViewDocument {
|
||||||
|
projectId: ObjectId
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewProperties {
|
||||||
|
projectId: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewDocumentToEntity(document: WithId<ViewDocument>): View {
|
||||||
|
return new View({
|
||||||
|
id: document._id.toString(),
|
||||||
|
projectId: document.projectId.toString(),
|
||||||
|
type: document.type,
|
||||||
|
name: document.name,
|
||||||
|
provider: document.provider as ViewProvider,
|
||||||
|
config: document.config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewsRepository {
|
||||||
|
|
||||||
|
private _views: Collection<ViewDocument>
|
||||||
|
|
||||||
|
constructor(db: Db) {
|
||||||
|
this._views = db.collection("projectsViews")
|
||||||
|
}
|
||||||
|
|
||||||
|
async createView(viewData: ViewProperties): Promise<View> {
|
||||||
|
const viewDocument = await this._views.insertOne({
|
||||||
|
...viewData,
|
||||||
|
projectId: new ObjectId(viewData.projectId)
|
||||||
|
});
|
||||||
|
return viewDocumentToEntity({
|
||||||
|
_id: viewDocument.insertedId,
|
||||||
|
projectId: new ObjectId(viewData.projectId),
|
||||||
|
type: viewData.type,
|
||||||
|
name: viewData.name,
|
||||||
|
provider: viewData.provider,
|
||||||
|
config: viewData.config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateView(view: View, properties: Partial<ViewProperties>): Promise<void> {
|
||||||
|
await this._views.updateOne({_id: new ObjectId(view.id)}, {
|
||||||
|
$set: {
|
||||||
|
name: properties.name,
|
||||||
|
config: properties.config
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(project: Project, filter: Partial<ViewProperties> = {}): Promise<View[]> {
|
||||||
|
const viewDocuments = await this._views.find({...filter, projectId: new ObjectId(project.id)}).toArray()
|
||||||
|
return viewDocuments.map(viewDocumentToEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<View | null> {
|
||||||
|
|
||||||
|
const viewDocument = await this._views.findOne({_id: new ObjectId(id)})
|
||||||
|
if (!viewDocument) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return viewDocumentToEntity(viewDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteView(view: View): Promise<void> {
|
||||||
|
await this._views.deleteOne({_id: new ObjectId(view.id)})
|
||||||
|
}
|
||||||
|
}
|
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||||
|
import {View, ViewProvider} from "./entities";
|
||||||
|
import {ViewsRepository} from "./ViewsRepository";
|
||||||
|
|
||||||
|
interface ViewsUseCasesDependencies {
|
||||||
|
viewsRepository: ViewsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewDataUpdate {
|
||||||
|
name: string
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewCreationProperties {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
provider: ViewProvider
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewsUseCases {
|
||||||
|
private _viewsRepository: ViewsRepository
|
||||||
|
|
||||||
|
constructor(dependencies: ViewsUseCasesDependencies) {
|
||||||
|
this._viewsRepository = dependencies.viewsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
async createView(context: ProjectContext, viewData: ViewCreationProperties): Promise<View> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
const {project} = context;
|
||||||
|
|
||||||
|
const view = await this._viewsRepository.createView({
|
||||||
|
projectId: project.id,
|
||||||
|
name: viewData.name,
|
||||||
|
type: viewData.type,
|
||||||
|
provider: viewData.provider,
|
||||||
|
config: viewData.config
|
||||||
|
})
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateView(context: ProjectContext, view: View, data: ViewDataUpdate): Promise<void> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||||
|
await this._viewsRepository.updateView(view, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllViews(context: ProjectContext): Promise<View[]> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
return await this._viewsRepository.findAll(context.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getViewsByType(context: ProjectContext, type: string): Promise<View[]> {
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
|
||||||
|
return await this._viewsRepository.findAll(context.project, {type});
|
||||||
|
}
|
||||||
|
}
|
53
server/src/core/features/views/entities/View.ts
Normal file
53
server/src/core/features/views/entities/View.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import {Identifiable} from "@app/core/Identifiable"
|
||||||
|
|
||||||
|
export enum ViewProvider {
|
||||||
|
Web = "Web",
|
||||||
|
Custom = "Custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewCreationProperties {
|
||||||
|
projectId: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
provider: ViewProvider
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewConstructor extends Identifiable {
|
||||||
|
projectId: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
provider: ViewProvider
|
||||||
|
config: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebViewProvider {
|
||||||
|
panels: {
|
||||||
|
name: string
|
||||||
|
order: number
|
||||||
|
metrics: {
|
||||||
|
metricId: string
|
||||||
|
order: number
|
||||||
|
config: unknown
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class View implements ViewConstructor {
|
||||||
|
|
||||||
|
readonly id: string
|
||||||
|
readonly projectId: string
|
||||||
|
readonly name: string
|
||||||
|
readonly type: string
|
||||||
|
readonly provider: ViewProvider
|
||||||
|
readonly config: unknown
|
||||||
|
|
||||||
|
constructor(data: ViewConstructor) {
|
||||||
|
this.id = data.id
|
||||||
|
this.projectId = data.projectId
|
||||||
|
this.type = data.type
|
||||||
|
this.name = data.name
|
||||||
|
this.provider = data.provider
|
||||||
|
this.config = data.config
|
||||||
|
}
|
||||||
|
}
|
1
server/src/core/features/views/entities/index.ts
Normal file
1
server/src/core/features/views/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./View"
|
28
server/src/core/features/views/index.ts
Normal file
28
server/src/core/features/views/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import {Feature, AppServices} from "@app/core/Feature";
|
||||||
|
import {MetricFeature} from "../metrics";
|
||||||
|
import {ViewsRepository} from "./ViewsRepository";
|
||||||
|
import {ViewsUseCases} from "./ViewsUseCases";
|
||||||
|
|
||||||
|
export * from "./entities"
|
||||||
|
|
||||||
|
export type ViewsFeatureDependencies = {
|
||||||
|
metricFeature: MetricFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewsFeature extends Feature {
|
||||||
|
|
||||||
|
name = "Views"
|
||||||
|
dependencies:ViewsFeatureDependencies
|
||||||
|
|
||||||
|
repository: ViewsRepository
|
||||||
|
useCases: ViewsUseCases
|
||||||
|
|
||||||
|
constructor(services: AppServices, dependencies: ViewsFeatureDependencies) {
|
||||||
|
super(services);
|
||||||
|
this.dependencies = dependencies
|
||||||
|
this.repository = new ViewsRepository(services.db)
|
||||||
|
this.useCases = new ViewsUseCases({
|
||||||
|
viewsRepository: this.repository
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
1
server/src/core/index.ts
Normal file
1
server/src/core/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./App"
|
143
server/src/http/HttpServer.ts
Normal file
143
server/src/http/HttpServer.ts
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import fastifyCors from "@fastify/cors";
|
||||||
|
import fastifyJwt from "@fastify/jwt";
|
||||||
|
import {TypeBoxTypeProvider} from "@fastify/type-provider-typebox";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {Caller} from "@app/core/features/projects/entities/Caller";
|
||||||
|
import {Project} from "@app/core/features/projects/entities/Project";
|
||||||
|
import {MissingAuthorization} from "@app/core/features/projects/exceptions/MissingAuthorization";
|
||||||
|
import {MissingResource} from "@app/core/features/projects/exceptions/MissingResource";
|
||||||
|
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||||
|
import {User} from "@app/core/features/users/entities/User";
|
||||||
|
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||||
|
import {SignupFailed, SignupFailedCode} from "@app/core/features/users/exceptions/SignupFailed";
|
||||||
|
import fastify, {FastifyInstance, FastifyLoggerInstance, InjectOptions, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault} from "fastify";
|
||||||
|
import {Logger} from "utils/logger";
|
||||||
|
import {projectsRoutes, usersRoutes, logsRoutes, metricsRoutes} from "./routes";
|
||||||
|
import {viewsRoutes} from "./routes/views.routes";
|
||||||
|
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||||
|
import {adminRoutes} from "./routes/admin.routes";
|
||||||
|
|
||||||
|
export type FastifyTypebox = FastifyInstance<
|
||||||
|
RawServerDefault,
|
||||||
|
RawRequestDefaultExpression<RawServerDefault>,
|
||||||
|
RawReplyDefaultExpression<RawServerDefault>,
|
||||||
|
FastifyLoggerInstance,
|
||||||
|
TypeBoxTypeProvider
|
||||||
|
>;
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { email: string } // payload type is used for signing and verifying
|
||||||
|
user: { email: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
data: { // [TODO] This file shouldn't know about these classes
|
||||||
|
user: User
|
||||||
|
caller: Caller
|
||||||
|
project: Project
|
||||||
|
context: ProjectContext
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpServer {
|
||||||
|
|
||||||
|
server: FastifyTypebox
|
||||||
|
app: App
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
this.server = fastify({
|
||||||
|
ajv: {
|
||||||
|
customOptions: {
|
||||||
|
strict: 'log',
|
||||||
|
keywords: ['kind', 'modifier']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).withTypeProvider<TypeBoxTypeProvider>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listen() {
|
||||||
|
this.server.listen({host: process.env.SERVER_HOST || "0.0.0.0", port: parseInt(process.env.SERVER_PORT || "8080")}, (err, address) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Server listening at ${address}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async inject(data: InjectOptions) {
|
||||||
|
return this.server.inject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setup() {
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
throw Error("Missing JWT_SECRET env var");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.server.register(fastifyCors);
|
||||||
|
await this.server.register(fastifyJwt, {secret: process.env.JWT_SECRET});
|
||||||
|
|
||||||
|
this.server.addHook("onSend", (req, reply, payload, done) => {
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
done();
|
||||||
|
return ;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.statusCode >= 200 && reply.statusCode < 300)
|
||||||
|
Logger.success(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||||
|
else if (reply.statusCode >= 500)
|
||||||
|
Logger.error(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||||
|
else
|
||||||
|
Logger.warn(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.setErrorHandler((err, req, reply) => {
|
||||||
|
let statusCode
|
||||||
|
if (err instanceof AuthenticationFailed) {
|
||||||
|
statusCode = 401
|
||||||
|
} else if (err instanceof SignupFailed) {
|
||||||
|
statusCode = 500
|
||||||
|
if (err.errorCode === SignupFailedCode.EmailAlreadyInUse) {
|
||||||
|
statusCode = 409
|
||||||
|
} else if (err.errorCode === SignupFailedCode.NotEnabled) {
|
||||||
|
statusCode = 401
|
||||||
|
}
|
||||||
|
} else if (err instanceof MissingAuthorization) {
|
||||||
|
statusCode = 401
|
||||||
|
} else if (err instanceof MissingResource) {
|
||||||
|
statusCode = 404;
|
||||||
|
} else if (err instanceof LimitReached) {
|
||||||
|
statusCode = 422;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode) {
|
||||||
|
reply.status(statusCode).send({error: err.message})
|
||||||
|
} else {
|
||||||
|
reply.send(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.server.addHook("onError", (req, reply, error, done) => {
|
||||||
|
if (!error.validation) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.register(usersRoutes(this.app));
|
||||||
|
this.server.register(projectsRoutes(this.app));
|
||||||
|
this.server.register(logsRoutes(this.app));
|
||||||
|
this.server.register(metricsRoutes(this.app));
|
||||||
|
this.server.register(viewsRoutes(this.app));
|
||||||
|
this.server.register(adminRoutes(this.app));
|
||||||
|
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
}
|
1
server/src/http/index.ts
Normal file
1
server/src/http/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./HttpServer"
|
20
server/src/http/routes/admin.routes.ts
Normal file
20
server/src/http/routes/admin.routes.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AppData, withData} from "http/utils";
|
||||||
|
|
||||||
|
export function adminRoutes(app: App) {
|
||||||
|
const adminUseCases = app.serverAdminFeature.useCases
|
||||||
|
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
|
||||||
|
server.get("/admin/server-stats",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Caller])]
|
||||||
|
},
|
||||||
|
async function (request) {
|
||||||
|
const {caller} = request.data;
|
||||||
|
return adminUseCases.getGlobalStats(caller);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
146
server/src/http/routes/admin.test.ts
Normal file
146
server/src/http/routes/admin.test.ts
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import {ADMIN_TOKEN, getAdminSucessTagId} from "@app/utils";
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {HttpServer} from "http/HttpServer";
|
||||||
|
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||||
|
|
||||||
|
describe("Admin", function () {
|
||||||
|
|
||||||
|
let server: HttpServer;
|
||||||
|
let users: TestUser[];
|
||||||
|
let project1Id: string
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
await deleteDatabase();
|
||||||
|
server = await buildTestServer();
|
||||||
|
users = await setupNewUsers(server.server);
|
||||||
|
users[2].SetAuthorization(ADMIN_TOKEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return error 401 if not using the ADMIN_TOKEN", async function() {
|
||||||
|
const resGet = await users[1].Get(`/admin/server-stats`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return 0 projects, 3 users, 0 logs, 0 metrics on initial state", async function() {
|
||||||
|
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(200);
|
||||||
|
expect(resGet.body).to.deep.equals({
|
||||||
|
userCount: 3,
|
||||||
|
projectCount: 0,
|
||||||
|
logCount: 0,
|
||||||
|
metricCount: 0,
|
||||||
|
metricHistoryEntryCount: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Server stats", function() {
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
// Create basic projects
|
||||||
|
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
await users[0].Post("/projects", {name: "p2"}); // Yep, don't need all projects
|
||||||
|
await users[1].Post("/projects", {name: "p3"});
|
||||||
|
project1Id = resAddProject1.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return 3 projects, 3 users, 3 logs, 0 metrics", async function() {
|
||||||
|
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(200);
|
||||||
|
expect(resGet.body).to.deep.equals({
|
||||||
|
userCount: 3,
|
||||||
|
projectCount: 3,
|
||||||
|
logCount: 3,
|
||||||
|
metricCount: 0,
|
||||||
|
metricHistoryEntryCount: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return 3 projects, 3 users, 4 logs, 1 metrics", async function() {
|
||||||
|
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||||
|
name: "metric 1"
|
||||||
|
})
|
||||||
|
|
||||||
|
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||||
|
expect(resGet.statusCode).to.deep.equals(200);
|
||||||
|
expect(resGet.body).to.deep.equals({
|
||||||
|
userCount: 3,
|
||||||
|
projectCount: 3,
|
||||||
|
logCount: 4,
|
||||||
|
metricCount: 1,
|
||||||
|
metricHistoryEntryCount: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return 3 projects, 3 users, 5 logs, 2 metrics, 3 metric history entries", async function() {
|
||||||
|
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||||
|
name: "metric 1"
|
||||||
|
})
|
||||||
|
|
||||||
|
const resAddMetric = await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||||
|
name: "metric 2"
|
||||||
|
})
|
||||||
|
|
||||||
|
const metricId = resAddMetric.body.id;
|
||||||
|
|
||||||
|
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 1});
|
||||||
|
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 2});
|
||||||
|
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 3});
|
||||||
|
|
||||||
|
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||||
|
expect(resGet.statusCode).to.deep.equals(200);
|
||||||
|
expect(resGet.body).to.deep.equals({
|
||||||
|
userCount: 3,
|
||||||
|
projectCount: 3,
|
||||||
|
logCount: 5,
|
||||||
|
metricCount: 2,
|
||||||
|
metricHistoryEntryCount: 3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Internal log to admin project", function() {
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
project1Id = resAddProject1.body.id;
|
||||||
|
const resAddTag = await users[0].Post(`/projects/${project1Id}/tags`, {
|
||||||
|
name: "Success",
|
||||||
|
color: "#00ff00"
|
||||||
|
});
|
||||||
|
process.env.ADMIN_PROJECT_ID = project1Id
|
||||||
|
process.env.ADMIN_SUCCESS_TAG_ID = resAddTag.body.id
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Add log when an user is created", async function() {
|
||||||
|
const resGetLogs1 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||||
|
expect(resGetLogs1.statusCode).to.equals(200);
|
||||||
|
expect(resGetLogs1.body).to.have.length(1);
|
||||||
|
|
||||||
|
const userEmail = "user.email@something.happy"
|
||||||
|
const resAddUser = await server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/signup",
|
||||||
|
payload: {
|
||||||
|
email: userEmail,
|
||||||
|
password: "SomePassword",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(resAddUser.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const resGetLogs2 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||||
|
expect(resGetLogs2.statusCode).to.equals(200);
|
||||||
|
expect(resGetLogs2.body).to.have.length(2);
|
||||||
|
expect(resGetLogs2.body[0].title).to.equals(`New user: ${userEmail}`);
|
||||||
|
expect(resGetLogs2.body[0].tags).to.have.length(1);
|
||||||
|
expect(resGetLogs2.body[0].tags[0].id).to.equals(getAdminSucessTagId());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
delete process.env.ADMIN_PROJECT_ID
|
||||||
|
delete process.env.ADMIN_SUCCESS_TAG_ID
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
4
server/src/http/routes/index.ts
Normal file
4
server/src/http/routes/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./users.routes";
|
||||||
|
export * from "./projects.routes";
|
||||||
|
export * from "./logs.routes";
|
||||||
|
export * from "./metrics.routes";
|
36
server/src/http/routes/logs.def.ts
Normal file
36
server/src/http/routes/logs.def.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import {Type} from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export const CreateLogBody = Type.Object({
|
||||||
|
title: Type.String(),
|
||||||
|
content: Type.String(),
|
||||||
|
tags: Type.Array(Type.String({minLength: 24, maxLength: 24}), {maxItems: 2})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateLogParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteLogParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
logId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateTagBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
color: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateTagBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
color: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateTagParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
tagId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteTagParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
tagId: Type.String(),
|
||||||
|
});
|
121
server/src/http/routes/logs.routes.ts
Normal file
121
server/src/http/routes/logs.routes.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {Static} from "@sinclair/typebox";
|
||||||
|
import {CreateLogBody, CreateLogParams, CreateTagBody, DeleteLogParams, DeleteTagParams, UpdateTagBody, UpdateTagParams} from "./logs.def";
|
||||||
|
import {LogsUseCases} from "@app/core/features/logs/LogsUseCases";
|
||||||
|
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||||
|
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AppData, rateLimit, withData} from "http/utils";
|
||||||
|
|
||||||
|
export type MetricRoutesDependencies = {
|
||||||
|
logService: LogsUseCases,
|
||||||
|
projectService: ProjectsUseCases,
|
||||||
|
userService: UsersUseCases
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logsRoutes(app: App) {
|
||||||
|
const logUseCases = app.logFeature.useCases
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
|
||||||
|
server.get("/projects/:projectId/logs",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])]
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const logs = await logUseCases.getLogs(context);
|
||||||
|
const tags = await logUseCases.getTags(context);
|
||||||
|
const formattedLogs = logs.map(log => ({
|
||||||
|
...log,
|
||||||
|
tags: log.tags.map(logTag => tags.find(tag => tag.id === logTag)).filter(t => t)
|
||||||
|
}))
|
||||||
|
reply.status(200).send(formattedLogs);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof CreateLogBody>, Params: Static<typeof CreateLogParams>}>(
|
||||||
|
"/projects/:projectId/logs",
|
||||||
|
{
|
||||||
|
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||||
|
schema: {body: CreateLogBody, params: CreateLogParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {title, content, tags} = request.body;
|
||||||
|
await logUseCases.addLog(context, {title, content, tags})
|
||||||
|
reply.status(201).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.delete<{Params: Static<typeof DeleteLogParams>}>(
|
||||||
|
"/projects/:projectId/logs/:logId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: DeleteLogParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {logId} = request.params
|
||||||
|
const log = await logUseCases.getLogById(context, logId)
|
||||||
|
await logUseCases.deleteLog(context, log);
|
||||||
|
reply.status(200).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/projects/:projectId/tags",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])]
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const tags = await logUseCases.getTags(context);
|
||||||
|
reply.status(200).send(tags);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof CreateTagBody>}>(
|
||||||
|
"/projects/:projectId/tags",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: CreateTagBody}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {name, color} = request.body;
|
||||||
|
const tag = await logUseCases.createTag(context, {name, color})
|
||||||
|
reply.status(201).send({id: tag.id});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.put<{Body: Static<typeof UpdateTagBody>, Params: Static<typeof UpdateTagParams>}>(
|
||||||
|
"/projects/:projectId/tags/:tagId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: UpdateTagBody, params: UpdateTagParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {name, color} = request.body;
|
||||||
|
const {tagId} = request.params;
|
||||||
|
const tag = await logUseCases.getTag(context, tagId);
|
||||||
|
await logUseCases.updateTag(context, tag, {name, color})
|
||||||
|
reply.status(200).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.delete<{Params: Static<typeof DeleteTagParams>}>(
|
||||||
|
"/projects/:projectId/tags/:tagId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: DeleteTagParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {tagId} = request.params
|
||||||
|
const tag = await logUseCases.getTag(context, tagId)
|
||||||
|
await logUseCases.deleteTag(context, tag);
|
||||||
|
reply.status(200).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
129
server/src/http/routes/logs.test.ts
Normal file
129
server/src/http/routes/logs.test.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {HttpServer} from "http/HttpServer";
|
||||||
|
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||||
|
|
||||||
|
describe("Logs", function () {
|
||||||
|
|
||||||
|
let server: HttpServer;
|
||||||
|
let users: TestUser[];
|
||||||
|
let project1: Record<string, unknown>
|
||||||
|
let project1ApiKey1: TestApiKey
|
||||||
|
|
||||||
|
let project2: Record<string, unknown>
|
||||||
|
let project2ApiKey1: TestApiKey
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
// Setup the server and app
|
||||||
|
await deleteDatabase();
|
||||||
|
server = await buildTestServer();
|
||||||
|
// Create basic users
|
||||||
|
users = await setupNewUsers(server.server);
|
||||||
|
// Create basic projects
|
||||||
|
await users[0].Post("/projects", {name: "p1"});
|
||||||
|
await users[0].Post("/projects", {name: "p2"});
|
||||||
|
await users[1].Post("/projects", {name: "p3"});
|
||||||
|
|
||||||
|
let response = await users[0].Get("/projects");
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(2);
|
||||||
|
|
||||||
|
project1 = response.body[0];
|
||||||
|
project2 = response.body[1];
|
||||||
|
|
||||||
|
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||||
|
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(2);
|
||||||
|
expect(response.body[0].name).to.equals("key1")
|
||||||
|
expect(response.body[0].projectId).to.equals(project1.id)
|
||||||
|
expect(response.body[0].key).to.not.be.undefined
|
||||||
|
expect(response.body[1].name).to.equals("key2")
|
||||||
|
expect(response.body[1].projectId).to.equals(project1.id)
|
||||||
|
expect(response.body[1].key).to.not.be.undefined
|
||||||
|
|
||||||
|
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||||
|
new TestApiKey(server.server, response.body[1].key)
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
expect(response.body[0].name).to.equals("key3")
|
||||||
|
expect(response.body[0].projectId).to.equals(project2.id)
|
||||||
|
expect(response.body[0].key).to.not.be.undefined
|
||||||
|
|
||||||
|
project2ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[User 1] Get logs with just the welcome log", async function() {
|
||||||
|
const response = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[K1-1 > P1] Get logs with just the welcome log", async function() {
|
||||||
|
const response = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[K1-1 > P1] Add log to p1", async function() {
|
||||||
|
const resAddLog = await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||||
|
title: "title1",
|
||||||
|
content: "content1",
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
expect(resAddLog.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const resGetLogs = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||||
|
expect(resGetLogs.statusCode).to.equals(200);
|
||||||
|
expect(resGetLogs.body).to.be.a("array");
|
||||||
|
expect(resGetLogs.body).to.have.length(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[K1-1 > P1] Get logs with 2 entries", async function() {
|
||||||
|
await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||||
|
title: "title1",
|
||||||
|
content: "content1",
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const resGetLogs = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||||
|
expect(resGetLogs.statusCode).to.equals(200);
|
||||||
|
expect(resGetLogs.body).to.be.a("array");
|
||||||
|
expect(resGetLogs.body).to.have.length(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[K2-1 > P1] Return error (401)", async function() {
|
||||||
|
const response = await project2ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("[K2-1 > P2] Get logs with just the welcome log", async function() {
|
||||||
|
const response = await project2ApiKey1.Get(`/projects/${project2.id}/logs`);
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
34
server/src/http/routes/metrics.def.ts
Normal file
34
server/src/http/routes/metrics.def.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import {Type} from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export const CreateMetricBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateMetricParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateMetricBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateMetricParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
metricId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SetMetricValueBody = Type.Object({
|
||||||
|
value: Type.Number(),
|
||||||
|
date: Type.Optional(Type.Number())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SetMetricValueParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
metricId: Type.String()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteHistoryRecordParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
metricId: Type.String(),
|
||||||
|
recordId: Type.String()
|
||||||
|
});
|
124
server/src/http/routes/metrics.routes.ts
Normal file
124
server/src/http/routes/metrics.routes.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {Static} from "@sinclair/typebox";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {SetMetricValueBody, SetMetricValueParams, CreateMetricBody, CreateMetricParams, UpdateMetricBody, UpdateMetricParams, DeleteHistoryRecordParams} from "./metrics.def";
|
||||||
|
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||||
|
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||||
|
import {AppData, rateLimit, withData} from "http/utils";
|
||||||
|
import {MetricsUseCases} from "@app/core/features/metrics";
|
||||||
|
|
||||||
|
export type MetricRouteDependencies = {
|
||||||
|
metricService: MetricsUseCases,
|
||||||
|
projectService: ProjectsUseCases,
|
||||||
|
userService: UsersUseCases
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricsRoutes(app: App) {
|
||||||
|
const metricsUseCases = app.metricFeature.useCases
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
|
||||||
|
server.get("/projects/:projectId/metrics",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])]
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const metrics = await metricsUseCases.getAllMetrics(context, true);
|
||||||
|
reply.status(200).send(metrics);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof CreateMetricBody>, Params: Static<typeof CreateMetricParams>}>(
|
||||||
|
"/projects/:projectId/metrics",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: CreateMetricBody, params: CreateMetricParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {name} = request.body;
|
||||||
|
const metric = await metricsUseCases.createMetric(context, {name})
|
||||||
|
reply.status(201).send({id: metric.id});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.put<{Body: Static<typeof UpdateMetricBody>, Params: Static<typeof UpdateMetricParams>}>(
|
||||||
|
"/projects/:projectId/metrics/:metricId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: UpdateMetricBody, params: UpdateMetricParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {metricId} = request.params
|
||||||
|
const {name} = request.body;
|
||||||
|
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||||
|
if (!metric) {
|
||||||
|
return reply.status(404).send();
|
||||||
|
}
|
||||||
|
await metricsUseCases.updateMetric(context, metric, {name});
|
||||||
|
reply.status(200).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get<{Params: Static<typeof SetMetricValueParams>}>(
|
||||||
|
"/projects/:projectId/metrics/:metricId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: SetMetricValueParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {metricId} = request.params
|
||||||
|
const metric = await metricsUseCases.getMetricById(context, metricId, true)
|
||||||
|
reply.status(200).send(metric);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof SetMetricValueBody>, Params: Static<typeof SetMetricValueParams>}>(
|
||||||
|
"/projects/:projectId/metrics/:metricId",
|
||||||
|
{
|
||||||
|
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||||
|
schema: {body: SetMetricValueBody, params: SetMetricValueParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {metricId} = request.params
|
||||||
|
const {value, date} = request.body;
|
||||||
|
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||||
|
await metricsUseCases.setMetricValue(context, metric, value, date);
|
||||||
|
reply.status(201).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.delete<{Params: Static<typeof SetMetricValueParams>}>(
|
||||||
|
"/projects/:projectId/metrics/:metricId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: SetMetricValueParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {metricId} = request.params
|
||||||
|
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||||
|
await metricsUseCases.deleteMetric(context, metric)
|
||||||
|
reply.status(200).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.delete<{Params: Static<typeof DeleteHistoryRecordParams>}>(
|
||||||
|
"/projects/:projectId/metrics/:metricId/history/:recordId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: DeleteHistoryRecordParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {metricId, recordId} = request.params
|
||||||
|
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||||
|
await metricsUseCases.deleteMetricHistoryRecord(context, metric, recordId);
|
||||||
|
reply.status(200).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
166
server/src/http/routes/metrics.test.ts
Normal file
166
server/src/http/routes/metrics.test.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import {sleep} from "@app/utils";
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {HttpServer} from "http/HttpServer";
|
||||||
|
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||||
|
|
||||||
|
describe("Metrics", function () {
|
||||||
|
|
||||||
|
let server: HttpServer;
|
||||||
|
let users: TestUser[];
|
||||||
|
let project1: Record<string, unknown>
|
||||||
|
let project1ApiKey1: TestApiKey
|
||||||
|
|
||||||
|
let project2: Record<string, unknown>
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
// Setup the server and app
|
||||||
|
await deleteDatabase();
|
||||||
|
server = await buildTestServer();
|
||||||
|
// Create basic users
|
||||||
|
users = await setupNewUsers(server.server);
|
||||||
|
// Create basic projects
|
||||||
|
await users[0].Post("/projects", {name: "p1"});
|
||||||
|
await users[0].Post("/projects", {name: "p2"});
|
||||||
|
await users[1].Post("/projects", {name: "p3"});
|
||||||
|
|
||||||
|
let response = await users[0].Get("/projects");
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(2);
|
||||||
|
|
||||||
|
project1 = response.body[0];
|
||||||
|
project2 = response.body[1];
|
||||||
|
|
||||||
|
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||||
|
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(2);
|
||||||
|
expect(response.body[0].name).to.equals("key1")
|
||||||
|
expect(response.body[0].projectId).to.equals(project1.id)
|
||||||
|
expect(response.body[0].key).to.not.be.undefined
|
||||||
|
expect(response.body[1].name).to.equals("key2")
|
||||||
|
expect(response.body[1].projectId).to.equals(project1.id)
|
||||||
|
expect(response.body[1].key).to.not.be.undefined
|
||||||
|
|
||||||
|
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||||
|
new TestApiKey(server.server, response.body[1].key)
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
expect(response.body[0].name).to.equals("key3")
|
||||||
|
expect(response.body[0].projectId).to.equals(project2.id)
|
||||||
|
expect(response.body[0].key).to.not.be.undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return empty metric list", async function() {
|
||||||
|
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(200);
|
||||||
|
expect(resGet.body).to.be.a("array");
|
||||||
|
expect(resGet.body).to.have.length(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return error 400 on bad payload", async function() {
|
||||||
|
let resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {});
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(400);
|
||||||
|
|
||||||
|
resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||||
|
type: "numeric"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Add a metric", async function() {
|
||||||
|
const resAdd = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||||
|
name: "metric 1",
|
||||||
|
type: "numeric"
|
||||||
|
});
|
||||||
|
expect(resAdd.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(200);
|
||||||
|
expect(resGet.body).to.be.a("array");
|
||||||
|
expect(resGet.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Get metrics with [Api Key 1]", async function() {
|
||||||
|
await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||||
|
name: "metric 1",
|
||||||
|
type: "numeric"
|
||||||
|
});
|
||||||
|
const response = await project1ApiKey1.Get(`/projects/${project1.id}/metrics`);
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Set value of a metric as [Api Key 1]", async function() {
|
||||||
|
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||||
|
name: "metric 1",
|
||||||
|
type: "numeric"
|
||||||
|
});
|
||||||
|
const metricId = resAddMetric.body.id
|
||||||
|
const resSetValue = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||||
|
value: 42
|
||||||
|
});
|
||||||
|
expect(resSetValue.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||||
|
|
||||||
|
expect(resGet.statusCode).to.equals(200);
|
||||||
|
expect(resGet.body).to.be.a("array");
|
||||||
|
expect(resGet.body).to.have.length(1);
|
||||||
|
expect(resGet.body[0].history).to.have.length(1);
|
||||||
|
expect(resGet.body[0].history[0].value).to.equals(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Simple rate limit test for [Api Key 1]", async function() {
|
||||||
|
process.env.RATE_LIMIT_ENABLED = 'true'
|
||||||
|
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||||
|
name: "metric 1",
|
||||||
|
type: "numeric"
|
||||||
|
});
|
||||||
|
const metricId = resAddMetric.body.id
|
||||||
|
const response1 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||||
|
value: 42
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response1.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const response2 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||||
|
value: 43
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response2.statusCode).to.equals(429);
|
||||||
|
|
||||||
|
await sleep(1100)
|
||||||
|
|
||||||
|
const response3 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||||
|
value: 44
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response3.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
process.env.RATE_LIMIT_ENABLED = 'false'
|
||||||
|
});
|
||||||
|
});
|
18
server/src/http/routes/projects.def.ts
Normal file
18
server/src/http/routes/projects.def.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import {Type} from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export const CreateProjectBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateApiKeyBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateApiKeyParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeleteApiKeyParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
apiKeyId: Type.String(),
|
||||||
|
});
|
90
server/src/http/routes/projects.routes.ts
Normal file
90
server/src/http/routes/projects.routes.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {Static} from "@sinclair/typebox";
|
||||||
|
import {CreateApiKeyBody, CreateApiKeyParams, CreateProjectBody, DeleteApiKeyParams} from "./projects.def";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AppData, withData} from "http/utils";
|
||||||
|
import {ProjectAuthorization} from "@app/core/features/projects/ProjectContext";
|
||||||
|
|
||||||
|
export function projectsRoutes(app: App) {
|
||||||
|
const projectService = app.projectFeature.service
|
||||||
|
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
server.post<{Body: Static<typeof CreateProjectBody>}>("/projects",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Caller])],
|
||||||
|
schema: {body: CreateProjectBody}
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const {name} = request.body;
|
||||||
|
const {caller} = request.data;
|
||||||
|
|
||||||
|
const project = await projectService.createProject(caller.asUser(), name);
|
||||||
|
|
||||||
|
reply.status(201).send({id: project.id});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/projects",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Caller])]
|
||||||
|
},
|
||||||
|
async function (request) {
|
||||||
|
const {caller} = request.data;
|
||||||
|
const projects = await projectService.getProjectsOfUser(caller.asUser());
|
||||||
|
return projects.map(project => project.getState())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/projects/:projectId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])]
|
||||||
|
},
|
||||||
|
async function (request) {
|
||||||
|
const {context} = request.data;
|
||||||
|
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||||
|
return context.project.getState();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server.get("/projects/:projectId/apiKeys",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])]
|
||||||
|
},
|
||||||
|
async function (request) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const apiKeys = projectService.getApiKeysOfProject(context);
|
||||||
|
return apiKeys
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof CreateApiKeyBody>, Params: Static<typeof CreateApiKeyParams>}>(
|
||||||
|
"/projects/:projectId/apiKeys",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: CreateApiKeyBody, params: CreateApiKeyParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {name} = request.body;
|
||||||
|
const apiKey = await projectService.generateApiKey(context, name);
|
||||||
|
reply.status(201).send({apiKey});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.delete<{Params: Static<typeof DeleteApiKeyParams>}>(
|
||||||
|
"/projects/:projectId/apiKeys/:apiKeyId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {params: DeleteApiKeyParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {apiKeyId} = request.params;
|
||||||
|
const apiKey = await projectService.getApiKeyById(context, apiKeyId);
|
||||||
|
await projectService.deleteApiKeys(context, apiKey);
|
||||||
|
reply.status(200).send({apiKey});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
118
server/src/http/routes/projects.test.ts
Normal file
118
server/src/http/routes/projects.test.ts
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {HttpServer} from "http/HttpServer";
|
||||||
|
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||||
|
|
||||||
|
describe("Projects", function () {
|
||||||
|
|
||||||
|
let server: HttpServer;
|
||||||
|
let users: TestUser[];
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
await deleteDatabase();
|
||||||
|
server = await buildTestServer();
|
||||||
|
users = await setupNewUsers(server.server);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Return empty project list", async function() {
|
||||||
|
const responseUser1 = await users[0].Get("/projects");
|
||||||
|
expect(responseUser1.statusCode).to.equals(200);
|
||||||
|
expect(responseUser1.body).to.be.a("array");
|
||||||
|
expect(responseUser1.body).to.have.length(0)
|
||||||
|
|
||||||
|
const responseUser2 = await users[1].Get("/projects");
|
||||||
|
expect(responseUser2.statusCode).to.equals(200);
|
||||||
|
expect(responseUser2.body).to.be.a("array");
|
||||||
|
expect(responseUser2.body).to.have.length(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Add 1 project", async function() {
|
||||||
|
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
expect(addRes.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const getRes = await users[0].Get("/projects");
|
||||||
|
expect(getRes.statusCode).to.equals(200);
|
||||||
|
expect(getRes.body).to.be.a("array");
|
||||||
|
expect(getRes.body).to.have.length(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Add 2 projects", async function() {
|
||||||
|
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||||
|
expect(addRes1.statusCode).to.equals(201);
|
||||||
|
expect(addRes2.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const getUser1Res = await users[0].Get("/projects");
|
||||||
|
expect(getUser1Res.statusCode).to.equals(200);
|
||||||
|
expect(getUser1Res.body).to.be.a("array");
|
||||||
|
expect(getUser1Res.body).to.have.length(2)
|
||||||
|
|
||||||
|
// I just want to be sure the 2nd user cannot get them
|
||||||
|
const getUser2Res = await users[1].Get("/projects");
|
||||||
|
expect(getUser2Res.statusCode).to.equals(200);
|
||||||
|
expect(getUser2Res.body).to.be.a("array");
|
||||||
|
expect(getUser2Res.body).to.have.length(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Check projects properties", async function() {
|
||||||
|
await users[0].Post("/projects", {name: "p1"});
|
||||||
|
await users[0].Post("/projects", {name: "p2"});
|
||||||
|
|
||||||
|
const getRes = await users[0].Get("/projects");
|
||||||
|
expect(getRes.statusCode).to.equals(200);
|
||||||
|
expect(getRes.body).to.be.a("array");
|
||||||
|
expect(getRes.body).to.have.length(2)
|
||||||
|
expect(getRes.body[0].name).to.equals("p1")
|
||||||
|
expect(getRes.body[1].name).to.equals("p2")
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Fail to add project (no valid authorization token)", async function() {
|
||||||
|
await users[0].Logout();
|
||||||
|
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
expect(addRes.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Fail to get projects (no valid authorization token)", async function() {
|
||||||
|
await users[0].Logout();
|
||||||
|
const getRes = await users[0].Get("/projects");
|
||||||
|
expect(getRes.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Projects have no Api key when created", async function() {
|
||||||
|
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||||
|
|
||||||
|
const project1Id = addRes1.body.id;
|
||||||
|
const project2Id = addRes2.body.id;
|
||||||
|
|
||||||
|
let response = await users[0].Get("/projects");
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(2);
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(0);
|
||||||
|
|
||||||
|
response = await users[0].Get(`/projects/${project2Id}/apiKeys`);
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.body).to.be.a("array");
|
||||||
|
expect(response.body).to.have.length(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Add Api key to project", async function() {
|
||||||
|
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||||
|
const project1Id = addRes1.body.id;
|
||||||
|
|
||||||
|
const response = await users[0].Post(`/projects/${project1Id}/apiKeys`, {name: "key1"});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
expect(response.body).to.be.a("object");
|
||||||
|
|
||||||
|
const resGetKeys = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||||
|
expect(resGetKeys.statusCode).to.equals(200);
|
||||||
|
expect(resGetKeys.body).to.be.a("array");
|
||||||
|
expect(resGetKeys.body).to.have.length(1);
|
||||||
|
expect(resGetKeys.body[0].name).to.equals("key1");
|
||||||
|
});
|
||||||
|
});
|
26
server/src/http/routes/users.def.ts
Normal file
26
server/src/http/routes/users.def.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import {Type} from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export const InfoBody = Type.Object({
|
||||||
|
adminToken: Type.String()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SignupBody = Type.Object({
|
||||||
|
email: Type.String({format: "email"}),
|
||||||
|
password: Type.String({minLength: 6}),
|
||||||
|
adminToken: Type.Optional(Type.String())
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LoginBody= Type.Object({
|
||||||
|
email: Type.String(),
|
||||||
|
password: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VerifyEmailQuerystring= Type.Object({
|
||||||
|
email: Type.String(),
|
||||||
|
emailConfirmationToken: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AdminChangeUserPasswordBody = Type.Object({
|
||||||
|
email: Type.String(),
|
||||||
|
newPassword: Type.String()
|
||||||
|
});
|
87
server/src/http/routes/users.routes.ts
Normal file
87
server/src/http/routes/users.routes.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {Static} from "@sinclair/typebox";
|
||||||
|
import {AdminChangeUserPasswordBody, InfoBody, LoginBody, SignupBody} from "./users.def";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AppData, rateLimit, withData} from "http/utils";
|
||||||
|
import {isRegistrationEnabled} from "@app/core/features/users/utils";
|
||||||
|
|
||||||
|
export function usersRoutes(app: App) {
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
|
||||||
|
const usersUseCases = app.userFeature.useCases
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof SignupBody>}>("/signup",
|
||||||
|
{
|
||||||
|
schema: {body: SignupBody}
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const {email, password, adminToken} = request.body;
|
||||||
|
await usersUseCases.signupUser(email, password, adminToken)
|
||||||
|
reply.status(201).send({success: true});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof LoginBody>}>("/login",
|
||||||
|
{
|
||||||
|
schema: {body: LoginBody}
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const {email, password} = request.body;
|
||||||
|
const user = await usersUseCases.logUser(email, password)
|
||||||
|
|
||||||
|
const token = server.jwt.sign({email});
|
||||||
|
|
||||||
|
return {...user.getState(), password: undefined, token};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/*server.get<{Querystring: Static<typeof VerifyEmailQuerystring>}>*/
|
||||||
|
/*("/signup-email-confirmation", {schema: {querystring: VerifyEmailQuerystring}}, async function (request, reply) {*/
|
||||||
|
/*[>const {email, emailConfirmationToken} = request.query;<]*/
|
||||||
|
/*[>const user = await app.userService.getUser({email});<]*/
|
||||||
|
/*[>if (!user || !await user.verifyEmail(email, emailConfirmationToken)) {<]*/
|
||||||
|
/*[>reply.status(401);<]*/
|
||||||
|
/*[>return {};<]*/
|
||||||
|
/*[>}<]*/
|
||||||
|
/*reply.send({success: true});*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof InfoBody>}>("/info",
|
||||||
|
{
|
||||||
|
schema: {body: InfoBody},
|
||||||
|
preValidation: [rateLimit(1, 1000)],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const {adminToken} = request.body;
|
||||||
|
return usersUseCases.getGlobalInfo(adminToken)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof AdminChangeUserPasswordBody>}>("/admin/change-user-password",
|
||||||
|
{
|
||||||
|
schema: {body: AdminChangeUserPasswordBody},
|
||||||
|
preValidation: [withData(app, [AppData.Caller]), rateLimit(1, 60000)],
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const {email, newPassword} = request.body;
|
||||||
|
const {caller} = request.data
|
||||||
|
return usersUseCases.adminChangeUserPassword(caller, email, newPassword)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/auth",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Caller])]
|
||||||
|
},
|
||||||
|
async function (request) {
|
||||||
|
const {caller} = request.data;
|
||||||
|
return {...caller.asUser().getState(), password: undefined};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/isUserRegistrationEnabled", async function () {
|
||||||
|
return {enabled: isRegistrationEnabled()};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
294
server/src/http/routes/users.test.ts
Normal file
294
server/src/http/routes/users.test.ts
Normal file
|
@ -0,0 +1,294 @@
|
||||||
|
import { ADMIN_TOKEN } from "@app/utils";
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {LightMyRequestResponse} from "fastify";
|
||||||
|
import {HttpServer} from "http/HttpServer";
|
||||||
|
import {buildTestServer, deleteDatabase} from "testUtils/apiTestHelper";
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
email: "user1@test.com",
|
||||||
|
password: "user1_password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "user2@test.com",
|
||||||
|
password: "user2_password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "user3@test.com",
|
||||||
|
password: "user3_password"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type TestRequestBody = {
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function TestRequiredBody(server: HttpServer, OriginalRequest: TestRequestBody) {
|
||||||
|
const payloadKeys = Object.keys(OriginalRequest.payload);
|
||||||
|
|
||||||
|
for (const key of payloadKeys) {
|
||||||
|
|
||||||
|
const request = JSON.parse(JSON.stringify(OriginalRequest));
|
||||||
|
delete request.payload[key];
|
||||||
|
const response = await server.inject(request);
|
||||||
|
expect(response.statusCode).to.equals(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: HttpServer;
|
||||||
|
|
||||||
|
async function logReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||||
|
return server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/login",
|
||||||
|
payload: {
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signupReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||||
|
return server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/signup",
|
||||||
|
payload: {
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Users", function () {
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
await deleteDatabase();
|
||||||
|
server = await buildTestServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Signup", function() {
|
||||||
|
it ("User registration is enabled", async function() {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/isUserRegistrationEnabled",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(200);
|
||||||
|
expect(response.json().enabled).to.equals(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Fail get current user (401) because no Bearer token is provided", async function() {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to sign up because email has wrong format", async function () {
|
||||||
|
const res = await signupReq("wrong", users[1].password)
|
||||||
|
expect(res.statusCode).to.equals(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to sign up because email is an empty string", async function () {
|
||||||
|
const res = await signupReq("", users[1].password)
|
||||||
|
expect(res.statusCode).to.equals(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to sign up because password is an empty string", async function () {
|
||||||
|
const res = await signupReq(users[1].email, "")
|
||||||
|
expect(res.statusCode).to.equals(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to sign up because email is already taken", async function () {
|
||||||
|
const resSignup1 = await signupReq(users[0].email, users[0].password)
|
||||||
|
expect(resSignup1.statusCode).to.equals(201);
|
||||||
|
|
||||||
|
const resSignup2 = await signupReq(users[0].email, users[1].password)
|
||||||
|
expect(resSignup2.statusCode).to.equals(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Succeed to sign up a new user", async function () {
|
||||||
|
const res = await signupReq(users[0].email, users[0].password)
|
||||||
|
expect(res.statusCode).to.equals(201);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
/*it("Should fail to log in with the new user because email is not verified", async function () {*/
|
||||||
|
|
||||||
|
/*const response = await server.inject({*/
|
||||||
|
/*method: "POST",*/
|
||||||
|
/*url: "/login",*/
|
||||||
|
/*payload: {*/
|
||||||
|
/*email: users[0].email,*/
|
||||||
|
/*password: users[0].password,*/
|
||||||
|
/*}*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
/*expect(response.statusCode).to.equals(403);*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
/*it("Fail to verify the email (wrong token)", async function () {*/
|
||||||
|
|
||||||
|
/*const response = await server.inject({*/
|
||||||
|
/*method: "GET",*/
|
||||||
|
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=this_is_a_random_string`,*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
/*expect(response.statusCode).to.equals(401);*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
/*it("Verify the email", async function () {*/
|
||||||
|
|
||||||
|
/*const user = await server.app.managers.userManager.FetchUserByEmail(users[0].email);*/
|
||||||
|
|
||||||
|
/*const response = await server.inject({*/
|
||||||
|
/*method: "GET",*/
|
||||||
|
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=${user?.GetDocument().emailConfirmationToken}`,*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
/*expect(response.statusCode).to.equals(200);*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
describe("Login", function() {
|
||||||
|
beforeEach(async function() {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/signup",
|
||||||
|
payload: {
|
||||||
|
email: users[0].email,
|
||||||
|
password: users[0].password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
})
|
||||||
|
|
||||||
|
it ("Fail (400) when a required property is missing", async function() {
|
||||||
|
await TestRequiredBody(server, {
|
||||||
|
method: "POST",
|
||||||
|
url: "/login",
|
||||||
|
payload: {
|
||||||
|
email: users[0].email,
|
||||||
|
password: users[0].password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to log in with wrong credentials", async function () {
|
||||||
|
const res = await logReq("abc", "def")
|
||||||
|
expect(res.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Fail to get current authenticated user because of wrong jwt token", async function() {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth",
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: "Bearer abcd"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to log in with password of another user", async function () {
|
||||||
|
const res = await logReq(users[1].email, users[0].password)
|
||||||
|
expect(res.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to log in with a total random password", async function () {
|
||||||
|
const res = await logReq(users[1].email, "qwertyasdfgzxcvb")
|
||||||
|
expect(res.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to log in with an unknown email but existing password", async function () {
|
||||||
|
const res = await logReq("this_email@doesnt.exist", users[0].password)
|
||||||
|
expect(res.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Fail to log in with an unknown email and unknown password", async function () {
|
||||||
|
const res = await logReq("this_email@doesnt.exist", "this_password_doesnt_exist")
|
||||||
|
expect(res.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Succeed to log in", async function () {
|
||||||
|
const res = await logReq(users[0].email, users[0].password)
|
||||||
|
expect(res.statusCode).to.equals(200);
|
||||||
|
expect(res.json().token).to.not.be.undefined;
|
||||||
|
expect(res.json().password).to.be.undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Succeed to get current authenticated user", async function() {
|
||||||
|
const resLogin = await logReq(users[0].email, users[0].password)
|
||||||
|
expect(resLogin.statusCode).to.equals(200);
|
||||||
|
|
||||||
|
const token = resLogin.json().token
|
||||||
|
|
||||||
|
const resAuth = await server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth",
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(resAuth.statusCode).to.equals(200);
|
||||||
|
expect(resAuth.json().password).to.be.undefined;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Password reset", function() {
|
||||||
|
beforeEach(async function() {
|
||||||
|
const response = await server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/signup",
|
||||||
|
payload: {
|
||||||
|
email: users[0].email,
|
||||||
|
password: users[0].password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.statusCode).to.equals(201);
|
||||||
|
})
|
||||||
|
|
||||||
|
it ("Admin succeed to force change an user password", async function() {
|
||||||
|
const newPassword = "my new password"
|
||||||
|
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||||
|
expect(resWrongPassword.statusCode).to.equals(401);
|
||||||
|
const resChangePassword = await server.inject({
|
||||||
|
method: "post",
|
||||||
|
url: "/admin/change-user-password",
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${ADMIN_TOKEN}`
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
email: users[0].email,
|
||||||
|
newPassword: newPassword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(resChangePassword.statusCode).to.equals(200);
|
||||||
|
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||||
|
expect(resGoodPassword.statusCode).to.equals(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("Non Admin fail to force change an user password", async function() {
|
||||||
|
const newPassword = "my new password"
|
||||||
|
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||||
|
expect(resWrongPassword.statusCode).to.equals(401);
|
||||||
|
const resChangePassword = await server.inject({
|
||||||
|
method: "post",
|
||||||
|
url: "/admin/change-user-password",
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: "Something random"
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
password: newPassword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(resChangePassword.statusCode).to.equals(401);
|
||||||
|
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||||
|
expect(resGoodPassword.statusCode).to.equals(401);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
29
server/src/http/routes/views.def.ts
Normal file
29
server/src/http/routes/views.def.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import {ViewProvider} from "@app/core/features/views";
|
||||||
|
import {Type} from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export const CreateViewBody = Type.Object({
|
||||||
|
type: Type.String(),
|
||||||
|
name: Type.String(),
|
||||||
|
provider: Type.Enum(ViewProvider),
|
||||||
|
config: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateViewParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateViewBody = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
config: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateViewParams = Type.Object({
|
||||||
|
projectId: Type.String(),
|
||||||
|
viewId: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const GetViewsQuerystring = Type.Object({
|
||||||
|
type: Type.Optional(Type.String()),
|
||||||
|
name: Type.Optional(Type.String()),
|
||||||
|
});
|
60
server/src/http/routes/views.routes.ts
Normal file
60
server/src/http/routes/views.routes.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import {FastifyInstance} from "fastify";
|
||||||
|
import {Static} from "@sinclair/typebox";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AppData, withData} from "http/utils";
|
||||||
|
import {CreateViewBody, CreateViewParams, GetViewsQuerystring, UpdateViewBody, UpdateViewParams} from "./views.def";
|
||||||
|
|
||||||
|
export function viewsRoutes(app: App) {
|
||||||
|
const viewsUseCases = app.viewsFeature.useCases
|
||||||
|
return async (server: FastifyInstance) => {
|
||||||
|
|
||||||
|
server.get<{Querystring: Static<typeof GetViewsQuerystring>}>("/projects/:projectId/views",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {querystring: GetViewsQuerystring}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {type} = request.query
|
||||||
|
let views = []
|
||||||
|
if (type) {
|
||||||
|
views = await viewsUseCases.getViewsByType(context, type)
|
||||||
|
} else {
|
||||||
|
views = await viewsUseCases.getAllViews(context)
|
||||||
|
}
|
||||||
|
reply.status(200).send(views);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post<{Body: Static<typeof CreateViewBody>, Params: Static<typeof CreateViewParams>}>(
|
||||||
|
"/projects/:projectId/views",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: CreateViewBody, params: CreateViewParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
await viewsUseCases.createView(context, request.body)
|
||||||
|
reply.status(201).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.put<{Body: Static<typeof UpdateViewBody>, Params: Static<typeof UpdateViewParams>}>(
|
||||||
|
"/projects/:projectId/views/:viewId",
|
||||||
|
{
|
||||||
|
preValidation: [withData(app, [AppData.Context])],
|
||||||
|
schema: {body: UpdateViewBody, params: UpdateViewParams}
|
||||||
|
},
|
||||||
|
async function (request, reply) {
|
||||||
|
const {context} = request.data;
|
||||||
|
const {viewId} = request.params
|
||||||
|
const view = await app.viewsFeature.repository.findOne(viewId)
|
||||||
|
if (!view) {
|
||||||
|
return reply.status(404).send();
|
||||||
|
}
|
||||||
|
await viewsUseCases.updateView(context, view, request.body)
|
||||||
|
reply.status(201).send({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
109
server/src/http/utils.ts
Normal file
109
server/src/http/utils.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {AdminUser, Caller} from "@app/core/features/projects/entities/Caller";
|
||||||
|
import {Project} from "@app/core/features/projects/entities/Project";
|
||||||
|
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||||
|
import {User} from "@app/core/features/users/entities";
|
||||||
|
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||||
|
import {ADMIN_TOKEN} from "@app/utils";
|
||||||
|
import {FastifyReply, FastifyRequest} from "fastify";
|
||||||
|
|
||||||
|
export function rateLimit(count: number, ms: number) {
|
||||||
|
let rateLimitCache: { [callerId: string]: { [url: string]: number } } = {}
|
||||||
|
setInterval(() => { rateLimitCache = {} }, ms)
|
||||||
|
|
||||||
|
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (process.env.RATE_LIMIT_ENABLED !== 'true') {
|
||||||
|
return ;
|
||||||
|
}
|
||||||
|
let callerId = "";
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
callerId = request.user.email
|
||||||
|
} catch (e) {
|
||||||
|
callerId = request.headers.authorization?.split(' ')[1] || ""
|
||||||
|
}
|
||||||
|
if (!rateLimitCache[callerId]) {
|
||||||
|
rateLimitCache[callerId] = {}
|
||||||
|
}
|
||||||
|
if (!rateLimitCache[callerId][request.url]) {
|
||||||
|
rateLimitCache[callerId][request.url] = 0
|
||||||
|
}
|
||||||
|
if (rateLimitCache[callerId][request.url] >= count) {
|
||||||
|
return reply.status(429).send({message: "Rate limit reached"});
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitCache[callerId][request.url] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AppData {
|
||||||
|
Caller,
|
||||||
|
Project,
|
||||||
|
Context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withData(app: App, required: AppData[]) {
|
||||||
|
const projectRepository = app.projectFeature.projectRepository
|
||||||
|
const userRepository = app.userFeature.repository
|
||||||
|
const apiKeyRepository = app.projectFeature.apiKeyRepository
|
||||||
|
return async (request: FastifyRequest) => {
|
||||||
|
let user
|
||||||
|
let caller
|
||||||
|
let project
|
||||||
|
let context
|
||||||
|
|
||||||
|
if (required.includes(AppData.Caller) || required.includes(AppData.Context)) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
const userData = await userRepository.findUserByEmail(request.user.email);
|
||||||
|
if (userData) {
|
||||||
|
user = new User(userData)
|
||||||
|
caller = new Caller(new User(userData))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const token = request.headers.authorization?.split(' ')[1]
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
throw new AuthenticationFailed("Unknown authorization token");
|
||||||
|
}
|
||||||
|
if (token === ADMIN_TOKEN) {
|
||||||
|
caller = new Caller(new AdminUser())
|
||||||
|
} else {
|
||||||
|
const apiKey = await apiKeyRepository.findByKey(token);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new AuthenticationFailed("Unknown authorization token");
|
||||||
|
}
|
||||||
|
caller = new Caller(apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!caller) {
|
||||||
|
throw new AuthenticationFailed("Unknown authorization token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required.includes(AppData.Project) || required.includes(AppData.Context)) {
|
||||||
|
const params = request.params as Record<string, unknown>
|
||||||
|
const projectId = params.projectId as string
|
||||||
|
project = await projectRepository.findProjectById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Unknown project id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (required.includes(AppData.Context)) {
|
||||||
|
if (!caller || !project) {
|
||||||
|
throw new Error("Missing information to create a project Context")
|
||||||
|
}
|
||||||
|
context = new ProjectContext(project, caller)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.data = {
|
||||||
|
user: user as User,
|
||||||
|
caller: caller as Caller,
|
||||||
|
project: project as Project,
|
||||||
|
context: context as ProjectContext
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.data
|
||||||
|
}
|
||||||
|
}
|
24
server/src/index.ts
Normal file
24
server/src/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {MongoClient, MongoClientOptions} from "mongodb";
|
||||||
|
import {HttpServer} from "./http";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log(`node version: ${process.version}`)
|
||||||
|
const {
|
||||||
|
DB_HOST,
|
||||||
|
DB_PORT,
|
||||||
|
DB_USER,
|
||||||
|
DB_PASSWORD,
|
||||||
|
DB_NAME,
|
||||||
|
} = process.env;
|
||||||
|
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}`;
|
||||||
|
const mongoOptions: MongoClientOptions = {ignoreUndefined: true}
|
||||||
|
const client = await MongoClient.connect(mongoUrl, mongoOptions);
|
||||||
|
const db = client.db(DB_NAME)
|
||||||
|
const app = new App(db);
|
||||||
|
await app.start();
|
||||||
|
|
||||||
|
const server = new HttpServer(app);
|
||||||
|
server.setup();
|
||||||
|
server.listen();
|
||||||
|
})()
|
241
server/src/testUtils/apiTestHelper.ts
Normal file
241
server/src/testUtils/apiTestHelper.ts
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
import {expect} from "chai";
|
||||||
|
import {App} from "@app/core";
|
||||||
|
import {LightMyRequestResponse} from "fastify";
|
||||||
|
import {FastifyTypebox, HttpServer} from "../http";
|
||||||
|
import {MongoClient} from "mongodb";
|
||||||
|
|
||||||
|
const {
|
||||||
|
DB_HOST,
|
||||||
|
DB_PORT,
|
||||||
|
DB_USER,
|
||||||
|
DB_PASSWORD,
|
||||||
|
DB_NAME,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}?authSource=admin`;
|
||||||
|
const mongoDbName = DB_NAME as string;
|
||||||
|
|
||||||
|
export async function deleteDatabase(): Promise<boolean> {
|
||||||
|
const client = await MongoClient.connect(mongoUrl);
|
||||||
|
return client.db(mongoDbName).dropDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersCredientials = [
|
||||||
|
{
|
||||||
|
email: "user1@test.com",
|
||||||
|
password: "user1_password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "user2@test.com",
|
||||||
|
password: "user2_password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "user3@test.com",
|
||||||
|
password: "user3_password"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function buildTestServer(): Promise<HttpServer> {
|
||||||
|
|
||||||
|
await deleteDatabase();
|
||||||
|
|
||||||
|
const client = await MongoClient.connect(mongoUrl);
|
||||||
|
const db = client.db(DB_NAME)
|
||||||
|
const app = new App(db);
|
||||||
|
await app.start();
|
||||||
|
|
||||||
|
const server = new HttpServer(app);
|
||||||
|
server.setup();
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseHelper {
|
||||||
|
response: LightMyRequestResponse,
|
||||||
|
/* eslint-disable-next-line */
|
||||||
|
body: any,
|
||||||
|
statusCode: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestApiKey {
|
||||||
|
private _server: FastifyTypebox;
|
||||||
|
private _authorization = "";
|
||||||
|
|
||||||
|
public get authorization() {return this._authorization;}
|
||||||
|
|
||||||
|
constructor(server: FastifyTypebox, apiKey: string) {
|
||||||
|
this._server = server;
|
||||||
|
this._authorization = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Get(url: string): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Delete(url: string): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestUser {
|
||||||
|
private _server: FastifyTypebox;
|
||||||
|
private _email = "";
|
||||||
|
private _password = "";
|
||||||
|
private _authorization = "";
|
||||||
|
|
||||||
|
public get authorization() {return this._authorization;}
|
||||||
|
|
||||||
|
constructor(server: FastifyTypebox,) {
|
||||||
|
this._server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Login(email: string, password: string): Promise<boolean> {
|
||||||
|
this._email = email;
|
||||||
|
this._password = password;
|
||||||
|
|
||||||
|
/*const user = await this._app.userService.getUser({email});*/
|
||||||
|
|
||||||
|
let response = await this._server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/signup",
|
||||||
|
payload: {
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*response = await this._server.inject({*/
|
||||||
|
/*method: "GET",*/
|
||||||
|
/*url: `/signup-email-confirmation?email=${email}&emailConfirmationToken=${user?.getData().emailConfirmationToken}`,*/
|
||||||
|
/*});*/
|
||||||
|
|
||||||
|
response = await this._server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/login",
|
||||||
|
payload: {
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(response.body);
|
||||||
|
this._authorization = body.token;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Logout() {
|
||||||
|
this._authorization = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async SetAuthorization(authorization: string) {
|
||||||
|
this._authorization = authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Get(url: string): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
|
||||||
|
async Delete(url: string): Promise<ResponseHelper> {
|
||||||
|
const response = await this._server.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
["Authorization"]: `Bearer ${this._authorization}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {response, body: response.json(), statusCode: response.statusCode};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupNewUsers(server: FastifyTypebox): Promise<TestUser[]> {
|
||||||
|
const usersWithAuthorization = await Promise.all(usersCredientials.map(async (userCredientials) =>{
|
||||||
|
const user = new TestUser(server);
|
||||||
|
await user.Login(userCredientials.email, userCredientials.password);
|
||||||
|
return user;
|
||||||
|
}));
|
||||||
|
return usersWithAuthorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function TestRequiredBody(method: string, url: string, user: TestUser, originalPayload: Record<string, unknown>) {
|
||||||
|
const payloadKeys = Object.keys(originalPayload);
|
||||||
|
|
||||||
|
for (const key of payloadKeys) {
|
||||||
|
|
||||||
|
const payload = JSON.parse(JSON.stringify(originalPayload));
|
||||||
|
delete payload[key];
|
||||||
|
if (method === "POST") {
|
||||||
|
const {response} = await user.Post(url, payload);
|
||||||
|
expect(response.statusCode).to.equals(400, `Key '${key}' in '${method} ${url}' is required but the server did not return a bad request error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
server/src/utils/index.ts
Normal file
19
server/src/utils/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_TOKEN = process.env.API_ADMIN_AUTHORIZATION as string;
|
||||||
|
|
||||||
|
export function getAdminProjectId(): string | undefined {
|
||||||
|
return process.env.ADMIN_PROJECT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminSucessTagId(): string | undefined {
|
||||||
|
return process.env.ADMIN_SUCCESS_TAG_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminFailTagId(): string | undefined {
|
||||||
|
return process.env.ADMIN_FAIL_TAG_ID;
|
||||||
|
}
|
44
server/src/utils/logger.ts
Normal file
44
server/src/utils/logger.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
const consoleColors = {
|
||||||
|
RESET: "\x1b[0m",
|
||||||
|
SUCCESS: "\x1b[32m",
|
||||||
|
WARNING: "\x1b[33m",
|
||||||
|
ERROR: "\x1b[31m",
|
||||||
|
DEBUG: "\x1b[36m"
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
static info(message: unknown) {
|
||||||
|
if (!process.env.LOGGER_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static debug(message: unknown) {
|
||||||
|
if (!process.env.LOGGER_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${consoleColors.DEBUG}${message}${consoleColors.RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static success(message: unknown) {
|
||||||
|
if (!process.env.LOGGER_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`${consoleColors.SUCCESS}${message}${consoleColors.RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static warn(message: unknown) {
|
||||||
|
if (!process.env.LOGGER_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`${consoleColors.WARNING}${message}${consoleColors.RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(message: unknown) {
|
||||||
|
if (!process.env.LOGGER_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`${consoleColors.ERROR}${message}${consoleColors.RESET}`);
|
||||||
|
}
|
||||||
|
}
|
111
server/tsconfig.json
Normal file
111
server/tsconfig.json
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "commonjs", /* Specify what module code is generated. */
|
||||||
|
"rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
"baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
"paths": {
|
||||||
|
"@app/*": ["./src/*"],
|
||||||
|
"utils/*": ["./src/utils/*"],
|
||||||
|
"http/*": ["./src/http/*"],
|
||||||
|
"testUtils/*": ["./src/testUtils/*"]
|
||||||
|
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"require": ["tsconfig-paths/register"]
|
||||||
|
}
|
||||||
|
}
|
2
web-app/.env.development
Normal file
2
web-app/.env.development
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
REACT_APP_API_URL=http://localhost:8080
|
||||||
|
REACT_APP_DOCS_URL=http://localhost:8000
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue